From 7af958f49b5049898371469349591bac43738a85 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Sat, 28 Mar 2026 22:36:23 -0400 Subject: [PATCH 01/70] feat(chat): render subagent activity inline as collapsible tool wrapper Remove SubagentExecutionMessage from the bottom-pinned section so subagent tool calls render inline within AssistantMessage via the existing SubagentToolCall collapsible component, consistent with all other tool calls. --- .../components/ChatMessageList/ChatMessageList.tsx | 12 ------------ .../components/ChatMessageList/ChatMessageList.tsx | 12 ------------ 2 files changed, 24 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.tsx index 5046915ee9a..d75c0c4a138 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.tsx @@ -18,7 +18,6 @@ import { MessageScrollbackRail } from "./components/MessageScrollbackRail"; import { PendingApprovalMessage } from "./components/PendingApprovalMessage"; import { PendingPlanApprovalMessage } from "./components/PendingPlanApprovalMessage"; import { PendingQuestionMessage } from "./components/PendingQuestionMessage"; -import { SubagentExecutionMessage } from "./components/SubagentExecutionMessage"; import { ThinkingMessage } from "./components/ThinkingMessage"; import { ToolPreviewMessage } from "./components/ToolPreviewMessage"; import { UserMessage } from "./components/UserMessage"; @@ -46,7 +45,6 @@ export function ChatMessageList({ workspaceCwd, activeTools, toolInputBuffers, - activeSubagents, pendingApproval, isApprovalSubmitting, onApprovalRespond, @@ -105,12 +103,6 @@ export function ChatMessageList({ }), [activeTools, toolInputBuffers], ); - const activeSubagentEntries = useMemo( - () => (activeSubagents ? [...activeSubagents.entries()] : []), - [activeSubagents], - ); - const hasSubagentActivity = activeSubagentEntries.length > 0; - const pendingPlanToolCallId = useMemo(() => { const anchorMessages: ChatMessage[] = [...renderedMessages]; if (interruptedPreview) { @@ -144,7 +136,6 @@ export function ChatMessageList({ const canShowPendingAssistantUi = isAwaitingAssistant && !currentMessage && - !hasSubagentActivity && !pendingApproval && !pendingQuestion; const shouldShowThinking = @@ -259,9 +250,6 @@ export function ChatMessageList({ onPlanRespond={onPlanRespond} /> ) : null} - {hasSubagentActivity ? ( - - ) : null} {pendingApproval && ( (activeSubagents ? [...activeSubagents.entries()] : []), - [activeSubagents], - ); - const hasSubagentActivity = activeSubagentEntries.length > 0; - const pendingPlanToolCallId = useMemo(() => { const anchorMessages: ChatMessage[] = [...renderedMessages]; if (interruptedPreview) { @@ -144,7 +136,6 @@ export function ChatMessageList({ const canShowPendingAssistantUi = isAwaitingAssistant && !currentMessage && - !hasSubagentActivity && !pendingApproval && !pendingQuestion; const shouldShowThinking = @@ -259,9 +250,6 @@ export function ChatMessageList({ onPlanRespond={onPlanRespond} /> ) : null} - {hasSubagentActivity ? ( - - ) : null} {pendingApproval && ( Date: Sun, 29 Mar 2026 20:08:10 -0400 Subject: [PATCH 02/70] fix(chat): add gradient fade-out and bottom padding to input footer Removes top padding from ChatInputDropZone so content is flush with the bottom of the scroll area, and adds a CSS pseudo-element gradient that fades the conversation content into the background above the input bar. --- .../components/ChatInputFooter/ChatInputFooter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx index dac0d7317aa..492c24278d5 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx @@ -115,7 +115,7 @@ export function ChatInputFooter({ ); return ( - + {(dragType) => (
{errorMessage && ( From 4cdc8ca5034215301e95abceba29296b9bc647d9 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Sun, 29 Mar 2026 20:08:16 -0400 Subject: [PATCH 03/70] =?UTF-8?q?feat(ui):=20redesign=20ToolInput/ToolOutp?= =?UTF-8?q?ut/ToolHeader=20=E2=80=94=20compact,=20monospaced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ToolInput/ToolOutput: replace CodeBlock with plain
, remove p-4
  padding, switch to bg-muted/30 backgrounds, rename labels to
  "Input"/"Output", apply font-mono throughout
- ToolHeader: reduce px-2.5 → px-1, add rounded-b-md, swap chevron
  direction on hover (right=collapsed, down=expanded), add open prop
- Tool: add font-mono to outer Collapsible wrapper
---
 .../ui/src/components/ai-elements/tool.tsx    | 109 ++++++++++--------
 1 file changed, 62 insertions(+), 47 deletions(-)

diff --git a/packages/ui/src/components/ai-elements/tool.tsx b/packages/ui/src/components/ai-elements/tool.tsx
index ca5e6a707ba..62f7d4f797e 100644
--- a/packages/ui/src/components/ai-elements/tool.tsx
+++ b/packages/ui/src/components/ai-elements/tool.tsx
@@ -4,6 +4,7 @@ import {
 	CheckCircleIcon,
 	CheckIcon,
 	ChevronDownIcon,
+	ChevronRightIcon,
 	CircleIcon,
 	ClockIcon,
 	WrenchIcon,
@@ -11,14 +12,13 @@ import {
 	XIcon,
 } from "lucide-react";
 import type { ComponentProps, ReactNode } from "react";
-import { isValidElement } from "react";
+import { useState } from "react";
 import { cn } from "../../lib/utils";
 import {
 	Collapsible,
 	CollapsibleContent,
 	CollapsibleTrigger,
 } from "../ui/collapsible";
-import { CodeBlock } from "./code-block";
 
 /** TanStack AI native states + derived output states. */
 export type ToolDisplayState =
@@ -37,7 +37,7 @@ export type ToolProps = ComponentProps;
 export const Tool = ({ className, ...props }: ToolProps) => (
 	 (
-	
-		
- - - {getToolDisplayName(title, type)} - -
-
- {getStatusIcon(state)} - -
-
-); +}: ToolHeaderProps) => { + const [isHovered, setIsHovered] = useState(false); + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + {...props} + > +
+ {isHovered ? ( + open ? ( + + ) : ( + + ) + ) : ( + + )} + + {getToolDisplayName(title, type)} + +
+
+ {getStatusIcon(state)} + +
+
+ ); +}; export type ToolContentProps = ComponentProps; export const ToolContent = ({ className, ...props }: ToolContentProps) => ( { const displayCode = formatJson(input); return ( -
+

- Parameters + Input

-
- -
+
+				{displayCode}
+			
); }; @@ -147,6 +164,15 @@ export type ToolOutputProps = ComponentProps<"div"> & { errorText?: string; }; +function formatOutput(output: unknown): string { + if (typeof output === "string") return output; + try { + return JSON.stringify(output, null, 2); + } catch { + return String(output); + } +} + export const ToolOutput = ({ className, output, @@ -157,32 +183,21 @@ export const ToolOutput = ({ return null; } - let Output =
{output as ReactNode}
; - - if (typeof output === "object" && !isValidElement(output)) { - Output = ( - - ); - } else if (typeof output === "string") { - Output = ; - } - return ( -
+

- {errorText ? "Error" : "Result"} + {errorText ? "Error" : "Output"}

-
- {errorText &&
{errorText}
} - {Output} -
+ {errorText ?? formatOutput(output)} +
); }; From 40f9b8c3c9a20a45e2ee47cee12fe3a599931482 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Sun, 29 Mar 2026 20:08:22 -0400 Subject: [PATCH 04/70] fix(ui): stop scroll anchor when clicking tool call triggers Expanding a tool call was causing the scroll container to jump. Detect clicks on [data-tool-trigger] elements and call stopScroll() so the stick-to-bottom behaviour doesn't fight the user interaction. --- .../components/ai-elements/conversation.tsx | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/ai-elements/conversation.tsx b/packages/ui/src/components/ai-elements/conversation.tsx index 0b2cda884c0..0ec29136468 100644 --- a/packages/ui/src/components/ai-elements/conversation.tsx +++ b/packages/ui/src/components/ai-elements/conversation.tsx @@ -27,12 +27,27 @@ export type ConversationContentProps = ComponentProps< export const ConversationContent = ({ className, ...props -}: ConversationContentProps) => ( - -); +}: ConversationContentProps) => { + const { stopScroll } = useStickToBottomContext(); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if ((e.target as Element).closest("[data-tool-trigger]")) { + stopScroll(); + } + }, + [stopScroll], + ); + + return ( + + ); +}; export type ConversationEmptyStateProps = ComponentProps<"div"> & { title?: string; From 130f6774529fc3a76544e6c551dfdddace8e5988 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Sun, 29 Mar 2026 20:08:29 -0400 Subject: [PATCH 05/70] feat(ui): add ToolCallRow and refactor tool components Introduces a shared ToolCallRow component that encapsulates the common collapsible row pattern: icon/chevron, ShimmerLabel title, muted description, status slot, left-border content area, and an optional headerExtra element for out-of-trigger action buttons. Refactors BashTool, WebSearchTool, WebFetchTool, and FileDiffTool to use ToolCallRow, removing duplicated hover/chevron/collapsible logic from each. All tools now have consistent: font-mono, px-1 header padding, rounded-b-md on hover, ml-2.5 border-l content alignment, and chevron-right/down hover behaviour. --- .../src/components/ai-elements/bash-tool.tsx | 132 ++++-------- .../components/ai-elements/file-diff-tool.tsx | 203 ++++++++---------- .../components/ai-elements/tool-call-row.tsx | 148 +++++++++++++ .../components/ai-elements/web-fetch-tool.tsx | 79 +++---- .../ai-elements/web-search-tool.tsx | 77 ++----- 5 files changed, 329 insertions(+), 310 deletions(-) create mode 100644 packages/ui/src/components/ai-elements/tool-call-row.tsx diff --git a/packages/ui/src/components/ai-elements/bash-tool.tsx b/packages/ui/src/components/ai-elements/bash-tool.tsx index ec73e1d2d83..c322b56c199 100644 --- a/packages/ui/src/components/ai-elements/bash-tool.tsx +++ b/packages/ui/src/components/ai-elements/bash-tool.tsx @@ -1,15 +1,9 @@ "use client"; -import { CheckIcon, TerminalIcon, XIcon } from "lucide-react"; -import { useMemo, useState } from "react"; +import { TerminalIcon } from "lucide-react"; +import { useMemo } from "react"; import { cn } from "../../lib/utils"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "../ui/collapsible"; -import { Loader } from "./loader"; -import { ShimmerLabel } from "./shimmer-label"; +import { ToolCallRow } from "./tool-call-row"; type BashToolState = | "input-streaming" @@ -46,10 +40,7 @@ export const BashTool = ({ state, className, }: BashToolProps) => { - const [isOutputExpanded, setIsOutputExpanded] = useState(false); - const isPending = state === "input-streaming" || state === "input-available"; - const isSuccess = exitCode === 0; const isError = exitCode !== undefined && exitCode !== 0; const commandSummary = useMemo( @@ -60,89 +51,48 @@ export const BashTool = ({ const hasOutput = Boolean(command || stdout || stderr); return ( - hasOutput && setIsOutputExpanded(open)} - open={hasOutput ? isOutputExpanded : false} + - - - - {hasOutput && ( - + {stdout} + )} - > -
- {/* Command */} - {command && ( -
- $ - - {command} - -
- )} - {/* Stdout */} - {stdout && ( -
- {stdout} -
- )} - - {/* Stderr */} - {stderr && ( -
- {stderr} -
- )} -
-
- )} -
+ {/* Stderr */} + {stderr && ( +
+ {stderr} +
+ )} + + ) : 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 32b2200a3e6..3f732b8a5d7 100644 --- a/packages/ui/src/components/ai-elements/file-diff-tool.tsx +++ b/packages/ui/src/components/ai-elements/file-diff-tool.tsx @@ -10,6 +10,7 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { ShimmerLabel } from "./shimmer-label"; +import { ToolCallRow } from "./tool-call-row"; type FileDiffToolState = | "input-streaming" @@ -120,12 +121,10 @@ export const FileDiffTool = ({ className, }: FileDiffToolProps) => { const hasExpandedRenderer = Boolean(renderExpandedContent); - const [expanded, setExpanded] = useState(hasExpandedRenderer); const [hasAutoExpanded, setHasAutoExpanded] = useState(false); useEffect(() => { if (!hasAutoExpanded && hasExpandedRenderer) { - setExpanded(true); setHasAutoExpanded(true); } }, [hasAutoExpanded, hasExpandedRenderer]); @@ -166,114 +165,97 @@ export const FileDiffTool = ({ [filePath, oldString, newString, content, isWriteMode], ); - return ( -
- {/* Header - fixed height */} - {/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: interactive tool header */} -
+ {isWriteMode ? "Writing file..." : "Editing file..."} + + ) : ( + + + {isWriteMode ? "Wrote" : "Edited"} + {" "} + {canOpenFile && filePath ? ( + + ) : ( + + {filePath ? extractFilename(filePath) : "file"} + )} - onClick={() => hasDiff && setExpanded((prev) => !prev)} - > -
- - {isStreaming && !filePath ? ( - - {isWriteMode ? "Writing file..." : "Editing file..."} - - ) : ( - - {isWriteMode ? "Wrote" : "Edited"}{" "} - {canOpenFile && filePath ? ( - - ) : ( - - {filePath ? extractFilename(filePath) : "file"} - - )} - - )} -
+
+ ); -
- {hasOpenMenu && filePath ? ( - - - - - { - event.stopPropagation(); - }} - > - onFilePathClick?.(filePath)}> - Open in File pane - - onDiffPathClick?.(filePath)}> - Open in Changes pane - - - - ) : ( - canOpenFile && - filePath && ( - - ) - )} + // Status slot: diff stats (+N -N) + const statusNode = + stats.additions > 0 || stats.removals > 0 ? ( + + {stats.additions > 0 && ( + +{stats.additions} + )} + {stats.removals > 0 && ( + -{stats.removals} + )} + + ) : null; - {/* Diff stats */} - {(stats.additions > 0 || stats.removals > 0) && ( - - {stats.additions > 0 && ( - +{stats.additions} - )} - {stats.removals > 0 && ( - -{stats.removals} - )} - - )} -
-
+ // Extra header element: "Open" button/dropdown (outside trigger to avoid stopPropagation) + const headerExtra = + hasOpenMenu && filePath ? ( + + + + + + onFilePathClick?.(filePath)}> + Open in File pane + + onDiffPathClick?.(filePath)}> + Open in Changes pane + + + + ) : canOpenFile && filePath ? ( + + ) : undefined; - {/* Diff body */} - {hasDiff && expanded && ( + return ( + + {hasDiff ? (
{renderExpandedContent ? ( @@ -308,16 +290,13 @@ export const FileDiffTool = ({
)}
- )} - - {/* Streaming indicator */} - {isStreaming && !hasDiff && ( -
+ ) : isStreaming ? ( +
...
- )} -
+ ) : undefined} + ); }; diff --git a/packages/ui/src/components/ai-elements/tool-call-row.tsx b/packages/ui/src/components/ai-elements/tool-call-row.tsx new file mode 100644 index 00000000000..1159d5a31cd --- /dev/null +++ b/packages/ui/src/components/ai-elements/tool-call-row.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { + CheckIcon, + ChevronDownIcon, + ChevronRightIcon, + XIcon, +} from "lucide-react"; +import type { ComponentType, ReactNode } from "react"; +import { useState } from "react"; +import { cn } from "../../lib/utils"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../ui/collapsible"; +import { Loader } from "./loader"; +import { ShimmerLabel } from "./shimmer-label"; + +export type ToolCallRowProps = { + /** Icon shown in the header (replaced by chevron on hover when expandable). */ + icon: ComponentType<{ className?: string }>; + /** + * Header title. A plain string is wrapped in a ShimmerLabel that pulses while + * `isPending` is true. Any other ReactNode is rendered as-is (useful when the + * title contains interactive elements like clickable file paths). + */ + title: ReactNode; + /** Optional muted text rendered after the title, truncated when too long. */ + description?: ReactNode; + /** When true the title shimmers and the default status shows a spinner. */ + isPending?: boolean; + /** When true the default status shows an X icon. */ + isError?: boolean; + /** + * Overrides the default status icon slot (spinner / check / X). + * Pass `null` to render nothing in the status slot. + * Omit (undefined) to use the default behaviour. + */ + statusNode?: ReactNode; + /** + * Extra element placed outside (after) the CollapsibleTrigger button — useful + * for action buttons that must not toggle expansion when clicked (e.g. "Open + * in pane"). + */ + headerExtra?: ReactNode; + /** Expandable content rendered inside the collapsible area with the left border. */ + children?: ReactNode; + className?: string; +}; + +/** + * Shared collapsible row used by every tool call type. + * + * Provides a consistent layout: + * [icon/chevron] [title] [description ...] | [status] [headerExtra?] + * └── collapsible content with left border ─────────────────────────────┘ + */ +export function ToolCallRow({ + icon: Icon, + title, + description, + isPending = false, + isError = false, + statusNode, + headerExtra, + children, + className, +}: ToolCallRowProps) { + const [isOpen, setIsOpen] = useState(false); + const [isHovered, setIsHovered] = useState(false); + const hasDetails = children != null && children !== false; + + const defaultStatus = ( +
+ {isPending ? ( + + ) : isError ? ( + + ) : ( + + )} +
+ ); + + const titleContent = + typeof title === "string" ? ( + + {title} + + ) : ( + title + ); + + return ( + hasDetails && setIsOpen(open)} + open={hasDetails ? isOpen : false} + > +
+ + + + {headerExtra} +
+ {hasDetails && ( + +
{children}
+
+ )} +
+ ); +} 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 657ea951d87..5e3718fb8be 100644 --- a/packages/ui/src/components/ai-elements/web-fetch-tool.tsx +++ b/packages/ui/src/components/ai-elements/web-fetch-tool.tsx @@ -1,10 +1,8 @@ "use client"; import { GlobeIcon } from "lucide-react"; -import { useState } from "react"; -import { cn } from "../../lib/utils"; import { Loader } from "./loader"; -import { ShimmerLabel } from "./shimmer-label"; +import { ToolCallRow } from "./tool-call-row"; type WebFetchToolState = | "input-streaming" @@ -43,66 +41,41 @@ export const WebFetchTool = ({ state, className, }: WebFetchToolProps) => { - 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) : ""; - return ( -
- {/* Header */} - {/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: interactive tool header */} -
hasContent && !isPending && setIsExpanded(!isExpanded)} - > -
- - {isPending ? ( - - Fetching - - ) : ( - Fetched - )} - {hostname && ( - {hostname} - )} -
- - {/* Status */} -
-
- {isPending ? ( - - ) : isError || !isSuccess ? ( - - {statusCode ? `Error ${statusCode}` : "Failed"} - - ) : bytes !== undefined ? ( - - {formatBytes(bytes)} - - ) : null} -
-
-
+ const statusNode = isPending ? ( +
+ +
+ ) : isError || !isSuccess ? ( + + {statusCode ? `Error ${statusCode}` : "Failed"} + + ) : bytes !== undefined ? ( + {formatBytes(bytes)} + ) : null; - {/* Content */} - {hasContent && isExpanded && ( -
+ return ( + + {hasContent ? ( +
 						{content}
 					
- )} -
+ ) : 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 e13d9427dcd..003a3a819d5 100644 --- a/packages/ui/src/components/ai-elements/web-search-tool.tsx +++ b/packages/ui/src/components/ai-elements/web-search-tool.tsx @@ -1,10 +1,8 @@ "use client"; -import { ExternalLinkIcon, SearchIcon } from "lucide-react"; -import { useState } from "react"; -import { cn } from "../../lib/utils"; +import { ExternalLinkIcon, GlobeIcon } from "lucide-react"; import { Loader } from "./loader"; -import { ShimmerLabel } from "./shimmer-label"; +import { ToolCallRow } from "./tool-call-row"; type WebSearchToolState = | "input-streaming" @@ -27,59 +25,30 @@ export const WebSearchTool = ({ state, className, }: WebSearchToolProps) => { - 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 */} - {/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: interactive tool header */} -
hasResults && !isPending && setIsExpanded(!isExpanded)} - > -
- - {isPending ? ( - - Searching - - ) : ( - Searched - )} - {truncatedQuery && ( - {truncatedQuery} - )} -
- - {/* Status */} -
-
- {isPending ? ( - - ) : isError ? ( - Failed - ) : ( - - {results.length} {results.length === 1 ? "result" : "results"} - - )} -
-
-
+ const statusNode = isPending ? ( +
+ +
+ ) : isError ? ( + Failed + ) : null; - {/* Results list */} - {hasResults && isExpanded && ( -
+ return ( + + {hasResults ? ( +
{results.map((result, idx) => ( ))}
- )} -
+ ) : undefined} + ); }; From f16d3c85d477d6eb9d5944e69e7f0d2868e75202 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Sun, 29 Mar 2026 20:08:36 -0400 Subject: [PATCH 06/70] feat(desktop): refactor tool call components to use shared ToolCallRow Migrates GenericToolCall, SupersetToolCall, SubagentToolCall, and ReadOnlyToolCall to use the new ToolCallRow component, eliminating per-component chevron/hover/collapsible boilerplate. Also updates ToolCallBlock dispatch: web_search tool variants without parsed results now fall through to GenericToolCall with a globe icon instead of WebSearchTool (which requires results to be expandable). Renames "Subagent" label to "Agent" with the agent type as a muted description. --- .../ReadOnlyToolCall/ReadOnlyToolCall.tsx | 123 ++++++---------- .../ToolCallBlock/ToolCallBlock.tsx | 18 ++- .../GenericToolCall/GenericToolCall.tsx | 79 +++------- .../SubagentToolCall/SubagentToolCall.tsx | 135 +++++++----------- .../SupersetToolCall/SupersetToolCall.tsx | 81 +++-------- 5 files changed, 153 insertions(+), 283 deletions(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx index e7baca5a4ab..1c33fae4637 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx @@ -1,22 +1,13 @@ -import { ShimmerLabel } from "@superset/ui/ai-elements/shimmer-label"; import { ToolInput, ToolOutput } from "@superset/ui/ai-elements/tool"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@superset/ui/collapsible"; +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; import { getToolName } from "ai"; import { - CheckIcon, ExternalLinkIcon, FileIcon, FileSearchIcon, FolderTreeIcon, - Loader2Icon, SearchIcon, - XIcon, } from "lucide-react"; -import { useState } from "react"; import { getWorkspaceToolFilePath } from "../../utils/file-paths"; import type { ToolPart } from "../../utils/tool-helpers"; import { @@ -78,7 +69,6 @@ export function ReadOnlyToolCall({ part, onOpenFileInPane, }: ReadOnlyToolCallProps) { - const [isOpen, setIsOpen] = useState(false); const args = getArgs(part); const toolName = normalizeToolName(getToolName(part)); const output = @@ -153,77 +143,48 @@ export function ReadOnlyToolCall({ const filePath = getWorkspaceToolFilePath({ toolName, args }); const canOpenFile = Boolean(filePath && onOpenFileInPane); + const headerExtra = + canOpenFile && filePath ? ( + + ) : undefined; + return ( - hasDetails && setIsOpen(open)} - open={hasDetails ? isOpen : false} + -
- - - - {canOpenFile && filePath && ( - - )} -
- {hasDetails && ( - - {isReadFileTool && !isError && readFileContent ? ( -
-
-								{readFileContent}
-							
-
- ) : ( -
- {part.input != null && } - {(output != null || isError) && ( - - )} -
- )} -
- )} -
+ {hasDetails ? ( + isReadFileTool && !isError && readFileContent ? ( +
+
+							{readFileContent}
+						
+
+ ) : ( +
+ {part.input != null && } + {(output != null || isError) && ( + + )} +
+ ) + ) : undefined} + ); } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx index 39f1a652e73..51d75d9e810 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx @@ -3,7 +3,7 @@ import { FileDiffTool } from "@superset/ui/ai-elements/file-diff-tool"; import { WebFetchTool } from "@superset/ui/ai-elements/web-fetch-tool"; import { WebSearchTool } from "@superset/ui/ai-elements/web-search-tool"; import { getToolName } from "ai"; -import { FileIcon, FolderIcon } from "lucide-react"; +import { FileIcon, FolderIcon, GlobeIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; import { posthog } from "renderer/lib/posthog"; import { useChangesStore } from "renderer/stores/changes"; @@ -445,10 +445,20 @@ export function ToolCallBlock({ ); } - // --- Web search → WebSearchTool --- - if (toolName === "web_search") { + // --- Web search → WebSearchTool (with results) or GenericToolCall (without) --- + if (toolName === "web_search" || toolName.includes("web_search")) { const { query, results } = getWebSearchViewModel({ args, result }); - return ; + if (results.length > 0) { + return ; + } + return ( + + ); } // --- Web fetch → WebFetchTool --- diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/GenericToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/GenericToolCall.tsx index 037f9e76077..48d21332053 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/GenericToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/GenericToolCall.tsx @@ -1,28 +1,23 @@ -import { ShimmerLabel } from "@superset/ui/ai-elements/shimmer-label"; import { ToolInput, ToolOutput } from "@superset/ui/ai-elements/tool"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@superset/ui/collapsible"; -import { CheckIcon, Loader2Icon, WrenchIcon, XIcon } from "lucide-react"; +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; +import { WrenchIcon } from "lucide-react"; import type { ComponentType } from "react"; -import { useState } from "react"; import type { ToolPart } from "../../../../utils/tool-helpers"; import { getGenericToolCallState } from "./getGenericToolCallState"; type GenericToolCallProps = { part: ToolPart; toolName: string; + subtitle?: string; icon?: ComponentType<{ className?: string }>; }; export function GenericToolCall({ part, toolName, + subtitle, icon: Icon = WrenchIcon, }: GenericToolCallProps) { - const [isOpen, setIsOpen] = useState(false); const { output, isError, displayState, errorText } = getGenericToolCallState(part); const isPending = @@ -30,54 +25,24 @@ export function GenericToolCall({ const hasDetails = part.input != null || output != null || isError; return ( - hasDetails && setIsOpen(open)} - open={hasDetails ? isOpen : false} + - - - - {hasDetails && ( - -
- {part.input != null && } - {(output != null || isError) && ( - - )} -
-
- )} -
+ {hasDetails ? ( +
+ {part.input != null && } + {(output != null || isError) && ( + + )} +
+ ) : undefined} + ); } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx index 6470cf5d12c..e140e3ff141 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx @@ -1,11 +1,6 @@ -import { ShimmerLabel } from "@superset/ui/ai-elements/shimmer-label"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@superset/ui/collapsible"; +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; import { cn } from "@superset/ui/lib/utils"; -import { BotIcon, CheckIcon, Loader2Icon, XIcon } from "lucide-react"; +import { BotIcon } from "lucide-react"; import { useId, useMemo, useState } from "react"; import { MarkdownToggleContent } from "renderer/components/Chat/components/MarkdownToggleContent"; import type { ToolPart } from "../../../../utils/tool-helpers"; @@ -28,7 +23,6 @@ export function SubagentToolCall({ args, result, }: SubagentToolCallProps) { - const [isOpen, setIsOpen] = useState(false); const [renderMarkdown, setRenderMarkdown] = useState(true); const markdownToggleId = useId(); const isPending = @@ -48,83 +42,60 @@ export function SubagentToolCall({ Boolean(parsed.modelId) || parsed.durationMs !== undefined; + // Title: "Agent" (foreground) — agentType goes in description (muted) + const titleNode = ( + + Agent{" "} + {agentType} + + ); + return ( - hasDetails && setIsOpen(open)} - open={hasDetails ? isOpen : false} + - - - - {hasDetails && ( - -
-
{task}
-
- {agentType} - {parsed.modelId ? ` • ${parsed.modelId}` : ""} - {parsed.durationMs !== undefined - ? ` • ${Math.round(parsed.durationMs)} ms` - : ""} + {parsed.tools.length > 0 ? ( +
+ {parsed.tools.map((tool, index) => ( + + {tool.name} + + ))}
- {parsed.tools.length > 0 ? ( -
- {parsed.tools.map((tool, index) => ( - - {tool.name} - - ))} -
- ) : null} - {parsed.text ? ( - - ) : null} -
- - )} - + ) : null} + {parsed.text ? ( + + ) : null} +
+ ) : undefined} +
); } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx index cd5e20ecc03..1f35b134530 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx @@ -1,12 +1,7 @@ -import { ShimmerLabel } from "@superset/ui/ai-elements/shimmer-label"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@superset/ui/collapsible"; -import { CheckIcon, Loader2Icon, WrenchIcon, XIcon } from "lucide-react"; +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; +import { WrenchIcon } from "lucide-react"; import type { ComponentType, ReactNode } from "react"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import type { ToolPart } from "../../../../utils/tool-helpers"; type SupersetToolCallProps = { @@ -31,7 +26,6 @@ export function SupersetToolCall({ icon: Icon = WrenchIcon, details, }: SupersetToolCallProps) { - const [isOpen, setIsOpen] = useState(false); const output = "output" in part ? (part as { output?: unknown }).output : undefined; const outputObject = @@ -55,57 +49,26 @@ export function SupersetToolCall({ const hasDetails = Boolean(details) || isError; return ( - hasDetails && setIsOpen(open)} - open={hasDetails ? isOpen : false} + - - - {hasDetails ? ( - -
- {details ? ( -
- {details} -
- ) : null} - {isError && errorText ? ( -
- {errorText} -
- ) : null} -
-
- ) : null} -
+
+ {details ? ( +
+ {details} +
+ ) : null} + {isError && errorText ? ( +
+ {errorText} +
+ ) : null} +
+ ) : undefined} + ); } From 0c24828e07982840afec336fd42029ab2801563b Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Sun, 29 Mar 2026 22:02:27 -0400 Subject: [PATCH 07/70] feat(chat): replace inline question UI with footer overlay - Add QuestionInputOverlay component: numbered options, "Something else" free-text row with pencil icon, Skip button, cross-fade to submit on type - Refactor AskUserQuestionToolCall to plain ToolCallRow (no inline answer UI) - Wire pendingQuestion/handleQuestionResponse/stopActiveResponse to footer in both ChatPane variants instead of ChatMessageList - Remove PendingQuestionMessage from ChatMessageList and clean up its props --- .../ChatInputFooter/ChatInputFooter.tsx | 172 +++++++++------- .../QuestionInputOverlay.tsx | 154 ++++++++++++++ .../components/QuestionInputOverlay/index.ts | 1 + .../AskUserQuestionToolCall.tsx | 192 +++++------------- .../ChatPaneInterface.tsx | 7 +- .../ChatInputFooter/ChatInputFooter.tsx | 174 +++++++++------- .../ChatMessageList/ChatMessageList.test.tsx | 3 - .../ChatMessageList/ChatMessageList.tsx | 14 +- .../ChatMessageList/ChatMessageList.types.ts | 5 - .../ChatPaneInterface/ChatPaneInterface.tsx | 7 +- .../ChatMessageList/ChatMessageList.test.tsx | 3 - .../ChatMessageList/ChatMessageList.tsx | 14 +- .../ChatMessageList/ChatMessageList.types.ts | 5 - 13 files changed, 410 insertions(+), 341 deletions(-) create mode 100644 apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx create mode 100644 apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/index.ts diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx index 492c24278d5..3ba47fa2327 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx @@ -22,6 +22,7 @@ import { ChatInputDropZone } from "./components/ChatInputDropZone"; import { ChatShortcuts } from "./components/ChatShortcuts"; import { FileDropOverlay } from "./components/FileDropOverlay"; import { LinkedIssues } from "./components/LinkedIssues"; +import { QuestionInputOverlay } from "./components/QuestionInputOverlay"; import { SlashCommandPreview } from "./components/SlashCommandPreview"; import type { LinkedIssue } from "./types"; import { getErrorMessage } from "./utils/getErrorMessage"; @@ -49,6 +50,14 @@ interface ChatInputFooterProps { onSend: (message: PromptInputMessage) => Promise | void; onStop: (e: React.MouseEvent) => void; onSlashCommandSend: (command: SlashCommand) => void; + pendingQuestion?: { + questionId: string; + question: string; + options?: { label: string; description?: string }[]; + } | null; + isQuestionSubmitting?: boolean; + onQuestionRespond?: (questionId: string, answer: string) => Promise; + onQuestionCancel?: () => void; } export function ChatInputFooter({ @@ -74,6 +83,10 @@ export function ChatInputFooter({ onSend, onStop, onSlashCommandSend, + pendingQuestion, + isQuestionSubmitting, + onQuestionRespond, + onQuestionCancel, }: ChatInputFooterProps) { useFocusPromptOnPane(isFocused); const [issueLinkOpen, setIssueLinkOpen] = useState(false); @@ -126,82 +139,91 @@ export function ChatInputFooter({ {errorMessage}

)} - - - -
- {showFocusHint && ( - - {focusShortcutText} to focus - - )} - + ) : ( + + + +
- - - - - {renderAttachment ?? - ((file) => )} - - - - - setIssueLinkOpen(true)} - /> - -
-
-
-
+ {showFocusHint && ( + + {focusShortcutText} to focus + + )} + + + + + + {renderAttachment ?? + ((file) => )} + + + + + setIssueLinkOpen(true)} + /> + +
+
+
+
+ )}
)} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx new file mode 100644 index 00000000000..e9d22f6ad64 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx @@ -0,0 +1,154 @@ +import { cn } from "@superset/ui/lib/utils"; +import { ArrowUpIcon, PencilIcon, XIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +type QuestionOption = { label: string; description?: string }; + +interface QuestionInputOverlayProps { + question: { + questionId: string; + question: string; + options?: QuestionOption[]; + }; + isSubmitting: boolean; + onRespond: (questionId: string, answer: string) => Promise; + onCancel: () => void; +} + +export function QuestionInputOverlay({ + question, + isSubmitting, + onRespond, + onCancel, +}: QuestionInputOverlayProps) { + const [customText, setCustomText] = useState(""); + const [activeLabel, setActiveLabel] = useState(null); + const inputRef = useRef(null); + const options = question.options ?? []; + + const isDisabled = isSubmitting || Boolean(activeLabel); + const hasCustomText = customText.trim().length > 0; + + const handleOption = async (label: string) => { + if (isDisabled) return; + setActiveLabel(label); + try { + await onRespond(question.questionId, label); + } catch { + setActiveLabel(null); + } + }; + + const handleCustom = async () => { + const trimmed = customText.trim(); + if (!trimmed || isDisabled) return; + setActiveLabel(trimmed); + try { + await onRespond(question.questionId, trimmed); + } catch { + setActiveLabel(null); + setCustomText(""); + } + }; + + return ( +
+ {/* Question */} +
+

+ {question.question} +

+ +
+ + {/* Options */} + {options.length > 0 && ( +
+ {options.map((option, i) => ( +
+ +
+ ))} +
+ )} + + {/* Something else */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: click-to-focus affordance */} +
{ + e.preventDefault(); + void handleCustom(); + }} + onClick={() => inputRef.current?.focus()} + > + + + + setCustomText(e.target.value)} + placeholder="Something else" + disabled={isDisabled} + className="flex-1 cursor-text bg-transparent py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground/40 disabled:cursor-not-allowed" + /> + {!isDisabled && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/index.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/index.ts new file mode 100644 index 00000000000..d7dc759eafd --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/index.ts @@ -0,0 +1 @@ +export { QuestionInputOverlay } from "./QuestionInputOverlay"; diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx index e136bd70331..106336ccf22 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx @@ -1,9 +1,7 @@ -import { MessageResponse } from "@superset/ui/ai-elements/message"; -import { UserQuestionTool } from "@superset/ui/ai-elements/user-question-tool"; +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; import { MessageCircleQuestionIcon } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; import type { ToolPart } from "../../../../utils/tool-helpers"; -import { SupersetToolCall } from "../SupersetToolCall"; interface QuestionToolOption { label: string; @@ -61,7 +59,6 @@ function toQuestionToolQuestions(value: unknown): QuestionToolQuestion[] { typeof optionRecord.description === "string" ? optionRecord.description.trim() : ""; - return description ? { label, description } : { label }; }) .filter((option): option is QuestionToolOption => option !== null) @@ -70,9 +67,7 @@ function toQuestionToolQuestions(value: unknown): QuestionToolQuestion[] { const header = typeof record.header === "string" ? record.header.trim() : ""; const multiSelect = - typeof record.multiSelect === "boolean" - ? record.multiSelect - : undefined; + typeof record.multiSelect === "boolean" ? record.multiSelect : undefined; return { question, @@ -119,151 +114,64 @@ function findAnswerForQuestion({ return undefined; } -function buildQuestionMarkdown({ - questions, - answers, -}: { - questions: QuestionToolQuestion[]; - answers: Record; -}): string { - if (questions.length === 0) return ""; - - const lines: string[] = ["### Agent question"]; - for (const [index, question] of questions.entries()) { - lines.push(""); - if (questions.length > 1) { - lines.push(`#### ${index + 1}`); - } - if (question.header) { - lines.push(`_${question.header}_`); - } - lines.push(question.question); - - const answer = findAnswerForQuestion({ - answers, - questionText: question.question, - }); - if (answer) { - lines.push(""); - lines.push(`**Answer:** ${answer}`); - } - } - - return lines.join("\n").trim(); -} - export function AskUserQuestionToolCall({ part, args, result, outputObject, nestedResultObject, - onAnswer, }: AskUserQuestionToolCallProps) { - const [optimisticAnswers, setOptimisticAnswers] = useState | null>(null); - const [isSubmittingLocally, setIsSubmittingLocally] = useState(false); - - useEffect(() => { - if (part.state === "output-available" || part.state === "output-error") { - setIsSubmittingLocally(false); - } - }, [part.state]); - - const questions = useMemo(() => { - return toQuestionToolQuestions(args.questions); - }, [args.questions]); - - const serverAnswers = useMemo(() => { - return toQuestionToolAnswers( - toRecord(result.answers) ?? - toRecord(outputObject?.answers) ?? - toRecord(nestedResultObject?.answers), - ); - }, [nestedResultObject?.answers, outputObject?.answers, result.answers]); - - const answers = optimisticAnswers ?? serverAnswers; - const markdown = buildQuestionMarkdown({ questions, answers }); - const hasOutput = - part.state === "output-available" || part.state === "output-error"; - const hasQuestions = questions.length > 0; - const canRespond = Boolean(onAnswer) && !hasOutput && !isSubmittingLocally; - - const messageBlock = markdown ? ( -
- - {markdown} - -
- ) : null; - - const handleSubmit = (submittedAnswers: Record): void => { - if (!onAnswer || isSubmittingLocally) return; - setOptimisticAnswers(submittedAnswers); - setIsSubmittingLocally(true); - - void Promise.resolve(onAnswer(part.toolCallId, submittedAnswers)).catch( - () => { - setOptimisticAnswers(null); - setIsSubmittingLocally(false); - }, - ); - }; + const questions = useMemo( + () => toQuestionToolQuestions(args.questions), + [args.questions], + ); - if (!hasQuestions) { - return ( - - ); - } + const answers = useMemo( + () => + toQuestionToolAnswers( + toRecord(result.answers) ?? + toRecord(outputObject?.answers) ?? + toRecord(nestedResultObject?.answers), + ), + [nestedResultObject?.answers, outputObject?.answers, result.answers], + ); - if (hasOutput || !onAnswer || optimisticAnswers) { - return ( -
- {messageBlock} - -
- ); - } + const isPending = + part.state !== "output-available" && part.state !== "output-error"; + const isError = part.state === "output-error"; - if (!canRespond) { - return ( -
- {messageBlock} - -
- ); - } + const description = + questions.length > 1 + ? `${questions.length} questions` + : (questions[0]?.question ?? undefined); return ( -
- {messageBlock} - handleSubmit({})} - /> -
+ + {questions.length > 0 ? ( +
+ {questions.map((q, i) => { + const answer = findAnswerForQuestion({ + answers, + questionText: q.question, + }); + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: questions don't have unique keys +
+
{q.question}
+ {answer && ( +
{answer}
+ )} +
+ ); + })} +
+ ) : undefined} +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx index 46369b9288e..36b00a7d8cb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx @@ -947,9 +947,6 @@ export function ChatPaneInterface({ pendingPlanApproval={pendingPlanApproval} isPlanSubmitting={planResponsePending} onPlanRespond={handlePlanResponse} - pendingQuestion={pendingQuestion} - isQuestionSubmitting={questionResponsePending} - onQuestionRespond={handleQuestionResponse} editingUserMessageId={editingUserMessageId} isEditSubmitting={isAwaitingAssistant} onStartEditUserMessage={setEditingUserMessageId} @@ -981,6 +978,10 @@ export function ChatPaneInterface({ onSubmitStart={() => setSubmitStatus("submitted")} onStop={handleStop} onSlashCommandSend={handleSlashCommandSend} + pendingQuestion={pendingQuestion} + isQuestionSubmitting={questionResponsePending} + onQuestionRespond={handleQuestionResponse} + onQuestionCancel={() => void stopActiveResponse()} />
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx index 15fbc777119..0c128cf35f7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx @@ -10,6 +10,7 @@ import type { ChatStatus, FileUIPart } from "ai"; import type React from "react"; import type { ReactNode } from "react"; import { useCallback, useRef, useState } from "react"; +import { QuestionInputOverlay } from "renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay"; import { IssueLinkCommand } from "renderer/components/Chat/ChatInterface/components/IssueLinkCommand"; import { SlashCommandInput } from "renderer/components/Chat/ChatInterface/components/SlashCommandInput"; import { useFocusPromptOnPane } from "renderer/components/Chat/ChatInterface/hooks/useFocusPromptOnPane"; @@ -54,6 +55,14 @@ interface ChatInputFooterProps { onSend: (message: PromptInputMessage) => Promise | void; onStop: (e: React.MouseEvent) => void; onSlashCommandSend: (command: SlashCommand) => void; + pendingQuestion?: { + questionId: string; + question: string; + options?: { label: string; description?: string }[]; + } | null; + isQuestionSubmitting?: boolean; + onQuestionRespond?: (questionId: string, answer: string) => Promise; + onQuestionCancel?: () => void; } export function ChatInputFooter({ @@ -81,6 +90,10 @@ export function ChatInputFooter({ onSend, onStop, onSlashCommandSend, + pendingQuestion, + isQuestionSubmitting, + onQuestionRespond, + onQuestionCancel, }: ChatInputFooterProps) { useFocusPromptOnPane(isFocused); const [issueLinkOpen, setIssueLinkOpen] = useState(false); @@ -133,83 +146,92 @@ export function ChatInputFooter({ {errorMessage}

)} - - - -
- {showFocusHint && ( - - {focusShortcutText} to focus - - )} - + ) : ( + + + +
- - - - - {renderAttachment ?? - ((file) => )} - - - - - setIssueLinkOpen(true)} - /> - -
-
-
-
+ {showFocusHint && ( + + {focusShortcutText} to focus + + )} + + + + + + {renderAttachment ?? + ((file) => )} + + + + + setIssueLinkOpen(true)} + /> + +
+
+
+
+ )}
)} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.test.tsx index b1398f16adb..0d5726ec720 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.test.tsx @@ -181,9 +181,6 @@ function createBaseProps( pendingPlanApproval: null, isPlanSubmitting: false, onPlanRespond: async () => {}, - pendingQuestion: null, - isQuestionSubmitting: false, - onQuestionRespond: async () => {}, editingUserMessageId: null, isEditSubmitting: false, onStartEditUserMessage: () => {}, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.tsx index d75c0c4a138..e625df2b839 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.tsx @@ -17,7 +17,6 @@ import { InterruptedFooter } from "./components/InterruptedFooter"; import { MessageScrollbackRail } from "./components/MessageScrollbackRail"; import { PendingApprovalMessage } from "./components/PendingApprovalMessage"; import { PendingPlanApprovalMessage } from "./components/PendingPlanApprovalMessage"; -import { PendingQuestionMessage } from "./components/PendingQuestionMessage"; import { ThinkingMessage } from "./components/ThinkingMessage"; import { ToolPreviewMessage } from "./components/ToolPreviewMessage"; import { UserMessage } from "./components/UserMessage"; @@ -51,9 +50,6 @@ export function ChatMessageList({ pendingPlanApproval, isPlanSubmitting, onPlanRespond, - pendingQuestion, - isQuestionSubmitting, - onQuestionRespond, editingUserMessageId, isEditSubmitting, onStartEditUserMessage, @@ -136,8 +132,7 @@ export function ChatMessageList({ const canShowPendingAssistantUi = isAwaitingAssistant && !currentMessage && - !pendingApproval && - !pendingQuestion; + !pendingApproval; const shouldShowThinking = canShowPendingAssistantUi && !pendingPlanApproval && @@ -264,13 +259,6 @@ export function ChatMessageList({ onRespond={onPlanRespond} /> )} - {pendingQuestion && ( - - )}
Promise; - pendingQuestion: ChatPendingQuestion; - isQuestionSubmitting: boolean; - onQuestionRespond: (questionId: string, answer: string) => Promise; editingUserMessageId: string | null; isEditSubmitting: boolean; onStartEditUserMessage: (messageId: string) => void; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx index 6e8610d7873..85b8d113150 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx @@ -1020,9 +1020,6 @@ export function ChatPaneInterface({ pendingPlanApproval={pendingPlanApproval} isPlanSubmitting={planResponsePending} onPlanRespond={handlePlanResponse} - pendingQuestion={pendingQuestion} - isQuestionSubmitting={questionResponsePending} - onQuestionRespond={handleQuestionResponse} editingUserMessageId={editingUserMessageId} isEditSubmitting={isAwaitingAssistant} onStartEditUserMessage={setEditingUserMessageId} @@ -1053,6 +1050,10 @@ export function ChatPaneInterface({ onSubmitStart={() => setSubmitStatus("submitted")} onStop={handleStop} onSlashCommandSend={handleSlashCommandSend} + pendingQuestion={pendingQuestion} + isQuestionSubmitting={questionResponsePending} + onQuestionRespond={handleQuestionResponse} + onQuestionCancel={() => void stopActiveResponse()} /> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.test.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.test.tsx index b1398f16adb..0d5726ec720 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.test.tsx @@ -181,9 +181,6 @@ function createBaseProps( pendingPlanApproval: null, isPlanSubmitting: false, onPlanRespond: async () => {}, - pendingQuestion: null, - isQuestionSubmitting: false, - onQuestionRespond: async () => {}, editingUserMessageId: null, isEditSubmitting: false, onStartEditUserMessage: () => {}, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.tsx index d75c0c4a138..e625df2b839 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.tsx @@ -17,7 +17,6 @@ import { InterruptedFooter } from "./components/InterruptedFooter"; import { MessageScrollbackRail } from "./components/MessageScrollbackRail"; import { PendingApprovalMessage } from "./components/PendingApprovalMessage"; import { PendingPlanApprovalMessage } from "./components/PendingPlanApprovalMessage"; -import { PendingQuestionMessage } from "./components/PendingQuestionMessage"; import { ThinkingMessage } from "./components/ThinkingMessage"; import { ToolPreviewMessage } from "./components/ToolPreviewMessage"; import { UserMessage } from "./components/UserMessage"; @@ -51,9 +50,6 @@ export function ChatMessageList({ pendingPlanApproval, isPlanSubmitting, onPlanRespond, - pendingQuestion, - isQuestionSubmitting, - onQuestionRespond, editingUserMessageId, isEditSubmitting, onStartEditUserMessage, @@ -136,8 +132,7 @@ export function ChatMessageList({ const canShowPendingAssistantUi = isAwaitingAssistant && !currentMessage && - !pendingApproval && - !pendingQuestion; + !pendingApproval; const shouldShowThinking = canShowPendingAssistantUi && !pendingPlanApproval && @@ -264,13 +259,6 @@ export function ChatMessageList({ onRespond={onPlanRespond} /> )} - {pendingQuestion && ( - - )} Promise; - pendingQuestion: ChatPendingQuestion; - isQuestionSubmitting: boolean; - onQuestionRespond: (questionId: string, answer: string) => Promise; editingUserMessageId: string | null; isEditSubmitting: boolean; onStartEditUserMessage: (messageId: string) => void; From 8d49b006f0714231ab0b24cb0e42bf6fc115795b Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Sun, 29 Mar 2026 22:19:19 -0400 Subject: [PATCH 08/70] feat(ui): replace tool call header indicators with braille spinner and left-side icons - Add BrailleSpinner component (braille chars, amber, matches sidenav style) - Show spinner in icon slot while pending, red X on error, icon on complete - Remove right-side status slot (no spinner, checkmark, or X on the right) - Remove title shimmer animation --- .../ai-elements/braille-spinner.tsx | 23 +++++++++++++ .../components/ai-elements/tool-call-row.tsx | 34 ++++++------------- 2 files changed, 34 insertions(+), 23 deletions(-) create mode 100644 packages/ui/src/components/ai-elements/braille-spinner.tsx diff --git a/packages/ui/src/components/ai-elements/braille-spinner.tsx b/packages/ui/src/components/ai-elements/braille-spinner.tsx new file mode 100644 index 00000000000..542de52e285 --- /dev/null +++ b/packages/ui/src/components/ai-elements/braille-spinner.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; +import { cn } from "../../lib/utils"; + +const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const INTERVAL = 80; + +export function BrailleSpinner({ className }: { className?: string }) { + const [frame, setFrame] = useState(0); + + useEffect(() => { + const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), INTERVAL); + return () => clearInterval(id); + }, []); + + return ( + + ); +} diff --git a/packages/ui/src/components/ai-elements/tool-call-row.tsx b/packages/ui/src/components/ai-elements/tool-call-row.tsx index 1159d5a31cd..3aa05f0a824 100644 --- a/packages/ui/src/components/ai-elements/tool-call-row.tsx +++ b/packages/ui/src/components/ai-elements/tool-call-row.tsx @@ -1,7 +1,6 @@ "use client"; import { - CheckIcon, ChevronDownIcon, ChevronRightIcon, XIcon, @@ -14,8 +13,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "../ui/collapsible"; -import { Loader } from "./loader"; -import { ShimmerLabel } from "./shimmer-label"; +import { BrailleSpinner } from "./braille-spinner"; export type ToolCallRowProps = { /** Icon shown in the header (replaced by chevron on hover when expandable). */ @@ -33,9 +31,8 @@ export type ToolCallRowProps = { /** When true the default status shows an X icon. */ isError?: boolean; /** - * Overrides the default status icon slot (spinner / check / X). - * Pass `null` to render nothing in the status slot. - * Omit (undefined) to use the default behaviour. + * Overrides the default status slot (X on error, nothing otherwise). + * Pass `null` to render nothing. Omit (undefined) to use the default. */ statusNode?: ReactNode; /** @@ -71,26 +68,11 @@ export function ToolCallRow({ const [isHovered, setIsHovered] = useState(false); const hasDetails = children != null && children !== false; - const defaultStatus = ( -
- {isPending ? ( - - ) : isError ? ( - - ) : ( - - )} -
- ); + const defaultStatus = null; const titleContent = typeof title === "string" ? ( - - {title} - + {title} ) : ( title ); @@ -121,6 +103,12 @@ export function ToolCallRow({ ) : ( ) + ) : isPending ? ( + + + + ) : isError ? ( + ) : ( )} From 4330a0bcb5fd75ba2b98774827ca6c51bf38ca02 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Sun, 29 Mar 2026 22:26:54 -0400 Subject: [PATCH 09/70] feat(chat): hide Question tool row while active, show with description after answered/interrupted - Thread isStreaming through ToolCallBlock to AskUserQuestionToolCall - Return null only when isPending && still streaming (overlay is active) - Show collapsed row with question text as description once answered or interrupted --- .../ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx | 3 +++ .../AskUserQuestionToolCall/AskUserQuestionToolCall.tsx | 5 +++++ .../components/AssistantMessage/AssistantMessage.tsx | 3 +++ 3 files changed, 11 insertions(+) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx index 51d75d9e810..1fbf556e9a3 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx @@ -50,6 +50,7 @@ interface ToolCallBlockProps { workspaceCwd?: string; sessionId?: string | null; organizationId?: string | null; + isStreaming?: boolean; onAnswer?: ( toolCallId: string, answers: Record, @@ -68,6 +69,7 @@ export function ToolCallBlock({ workspaceCwd, sessionId, organizationId, + isStreaming, onAnswer, }: ToolCallBlockProps) { const args = getArgs(part); @@ -493,6 +495,7 @@ export function ToolCallBlock({ result={result} outputObject={outputObject} nestedResultObject={nestedResultObject} + isStreaming={isStreaming} onAnswer={onAnswer} /> ); diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx index 106336ccf22..a46aecba43d 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx @@ -21,6 +21,7 @@ interface AskUserQuestionToolCallProps { result: Record; outputObject?: Record; nestedResultObject?: Record; + isStreaming?: boolean; onAnswer?: ( toolCallId: string, answers: Record, @@ -120,6 +121,7 @@ export function AskUserQuestionToolCall({ result, outputObject, nestedResultObject, + isStreaming, }: AskUserQuestionToolCallProps) { const questions = useMemo( () => toQuestionToolQuestions(args.questions), @@ -140,6 +142,9 @@ export function AskUserQuestionToolCall({ part.state !== "output-available" && part.state !== "output-error"; const isError = part.state === "output-error"; + // Hide while the overlay is actively showing (streaming = question is live) + if (isPending && isStreaming !== false) return null; + const description = questions.length > 1 ? `${questions.length} questions` diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx index 186b9b16e59..81a19a53d03 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx @@ -261,6 +261,7 @@ export function AssistantMessage({ sessionId={sessionId} organizationId={organizationId} workspaceCwd={workspaceCwd} + isStreaming={isStreaming} />, ); nodes.push(...getInlineToolStateNodes(part.id)); @@ -284,6 +285,7 @@ export function AssistantMessage({ sessionId={sessionId} organizationId={organizationId} workspaceCwd={workspaceCwd} + isStreaming={isStreaming} />, ); nodes.push(...getInlineToolStateNodes(part.id)); @@ -313,6 +315,7 @@ export function AssistantMessage({ sessionId={sessionId} organizationId={organizationId} workspaceCwd={workspaceCwd} + isStreaming={isStreaming} />, ); nodes.push(...getInlineToolStateNodes(previewPart.toolCallId)); From 255d90b85c7ceaa8300092a335201accc34f1826 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Sun, 29 Mar 2026 22:52:37 -0400 Subject: [PATCH 10/70] feat(chat): improve tool call UX and styling - Align tool call icon with chat text using -mx-1 negative margin - Remove overflow-hidden from MessageContent to avoid clipping - Hide description in tool call header when expanded - Show query field as plain text with Query/Response labels instead of raw JSON - Add subtle focus-visible ring to collapsible trigger button - Adjust content padding (pl-3 py-1) to align with heading text - Use "Type your answer..." placeholder when question has no options --- .../QuestionInputOverlay.tsx | 2 +- .../GenericToolCall/GenericToolCall.tsx | 23 +++++++++++++++++-- .../ui/src/components/ai-elements/message.tsx | 2 +- .../components/ai-elements/tool-call-row.tsx | 8 +++---- .../ui/src/components/ai-elements/tool.tsx | 6 ++++- 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx index e9d22f6ad64..8090bb79a5a 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx @@ -119,7 +119,7 @@ export function QuestionInputOverlay({ ref={inputRef} value={customText} onChange={(e) => setCustomText(e.target.value)} - placeholder="Something else" + placeholder={options.length > 0 ? "Something else" : "Type your answer..."} disabled={isDisabled} className="flex-1 cursor-text bg-transparent py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground/40 disabled:cursor-not-allowed" /> diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/GenericToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/GenericToolCall.tsx index 48d21332053..85f8860a648 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/GenericToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/GenericToolCall.tsx @@ -12,6 +12,14 @@ type GenericToolCallProps = { icon?: ComponentType<{ className?: string }>; }; +function getQueryFromInput(input: unknown): string | undefined { + if (input != null && typeof input === "object" && !Array.isArray(input)) { + const query = (input as Record).query; + if (typeof query === "string" && query.trim().length > 0) return query; + } + return undefined; +} + export function GenericToolCall({ part, toolName, @@ -23,6 +31,7 @@ export function GenericToolCall({ const isPending = part.state !== "output-available" && part.state !== "output-error"; const hasDetails = part.input != null || output != null || isError; + const query = getQueryFromInput(part.input); return ( {hasDetails ? ( -
- {part.input != null && } +
+ {query != null ? ( +
+

+ Query +

+

{query}

+
+ ) : ( + part.input != null && + )} {(output != null || isError) && ( )}
diff --git a/packages/ui/src/components/ai-elements/message.tsx b/packages/ui/src/components/ai-elements/message.tsx index 0851d1e1dd6..0e45ba6f9ea 100644 --- a/packages/ui/src/components/ai-elements/message.tsx +++ b/packages/ui/src/components/ai-elements/message.tsx @@ -56,7 +56,7 @@ export const MessageContent = ({ }: MessageContentProps) => (
hasDetails && setIsOpen(open)} open={hasDetails ? isOpen : false} > @@ -87,7 +87,7 @@ export function ToolCallRow({
{hasDetails && ( -
{children}
+
{children}
)} diff --git a/packages/ui/src/components/ai-elements/tool.tsx b/packages/ui/src/components/ai-elements/tool.tsx index 62f7d4f797e..cb1ceab9600 100644 --- a/packages/ui/src/components/ai-elements/tool.tsx +++ b/packages/ui/src/components/ai-elements/tool.tsx @@ -162,6 +162,7 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => { export type ToolOutputProps = ComponentProps<"div"> & { output?: unknown; errorText?: string; + label?: string; }; function formatOutput(output: unknown): string { @@ -177,16 +178,19 @@ export const ToolOutput = ({ className, output, errorText, + label, ...props }: ToolOutputProps) => { if (!(output || errorText)) { return null; } + const heading = errorText ? "Error" : (label ?? "Output"); + return (

- {errorText ? "Error" : "Output"} + {heading}

Date: Mon, 30 Mar 2026 08:42:17 -0400
Subject: [PATCH 11/70] fix(chat): keep answered question messages visible
 during active turn

Extract hasAnsweredQuestionToolCall to a shared utility and use it in
withoutActiveTurnAssistantHistory / getVisibleMessages so that assistant
messages containing an answered ask_user_question are kept visible in the
message list while a session is still running. Previously all assistant
messages from the active turn were hidden, causing questions and their
answers to disappear from chat history until the turn completed.
---
 .../ChatInterface/utils/messageHelpers.ts     | 31 +++++++++++++++++++
 .../utils/messageListHelpers.ts               |  6 +++-
 .../useWorkspaceChatDisplay.ts                |  6 +++-
 .../utils/messageListHelpers.ts               |  6 +++-
 4 files changed, 46 insertions(+), 3 deletions(-)
 create mode 100644 apps/desktop/src/renderer/components/Chat/ChatInterface/utils/messageHelpers.ts

diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/messageHelpers.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/messageHelpers.ts
new file mode 100644
index 00000000000..5f893bbf32d
--- /dev/null
+++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/messageHelpers.ts
@@ -0,0 +1,31 @@
+/**
+ * Returns true if an assistant message contains an ask_user_question/ask_user
+ * tool call that has already been answered (i.e. has a matching tool_result).
+ *
+ * Works with any message type that carries a `content: unknown[]` array — both
+ * the historical HistoryMessage and the display-layer ChatMessage.
+ */
+export function hasAnsweredQuestionToolCall(message: {
+	content: unknown[];
+}): boolean {
+	const questionCallIds = new Set();
+	const resultIds = new Set();
+	for (const part of message.content) {
+		const p = part as Record;
+		if (p.type === "tool_call") {
+			const name = typeof p.name === "string" ? p.name : "";
+			if (name === "ask_user_question" || name === "ask_user") {
+				const id = typeof p.id === "string" ? p.id : "";
+				if (id) questionCallIds.add(id);
+			}
+		}
+		if (p.type === "tool_result") {
+			const id = typeof p.id === "string" ? p.id : "";
+			if (id) resultIds.add(id);
+		}
+	}
+	return (
+		questionCallIds.size > 0 &&
+		[...questionCallIds].some((id) => resultIds.has(id))
+	);
+}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/utils/messageListHelpers.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/utils/messageListHelpers.ts
index 95c8ed8c8b9..bfc93eb7355 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/utils/messageListHelpers.ts
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/utils/messageListHelpers.ts
@@ -1,3 +1,4 @@
+import { hasAnsweredQuestionToolCall } from "renderer/components/Chat/ChatInterface/utils/messageHelpers";
 import type { ToolPart } from "renderer/components/Chat/ChatInterface/utils/tool-helpers";
 import { normalizeToolName } from "renderer/components/Chat/ChatInterface/utils/tool-helpers";
 import type {
@@ -100,7 +101,10 @@ export function getVisibleMessages({
 	const previousTurns = messages.slice(0, turnStartIndex);
 	const activeTurnNonAssistant = messages
 		.slice(turnStartIndex)
-		.filter((message) => message.role !== "assistant");
+		.filter(
+			(message) =>
+				message.role !== "assistant" || hasAnsweredQuestionToolCall(message),
+		);
 
 	return [...previousTurns, ...activeTurnNonAssistant];
 }
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay/useWorkspaceChatDisplay.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay/useWorkspaceChatDisplay.ts
index 8bb6ac97efd..d1b9806f1da 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay/useWorkspaceChatDisplay.ts
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay/useWorkspaceChatDisplay.ts
@@ -2,6 +2,7 @@ import type { AppRouter } from "@superset/host-service";
 import { workspaceTrpc } from "@superset/workspace-client";
 import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
 import { useEffect, useMemo, useRef, useState } from "react";
+import { hasAnsweredQuestionToolCall } from "renderer/components/Chat/ChatInterface/utils/messageHelpers";
 
 interface UseChatDisplayOptions {
 	sessionId: string | null;
@@ -73,7 +74,10 @@ function withoutActiveTurnAssistantHistory({
 	const previousTurns = messages.slice(0, turnStartIndex);
 	const activeTurnNonAssistant = messages
 		.slice(turnStartIndex)
-		.filter((message) => message.role !== "assistant");
+		.filter(
+			(message) =>
+				message.role !== "assistant" || hasAnsweredQuestionToolCall(message),
+		);
 
 	return [...previousTurns, ...activeTurnNonAssistant];
 }
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/utils/messageListHelpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/utils/messageListHelpers.ts
index 95c8ed8c8b9..bfc93eb7355 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/utils/messageListHelpers.ts
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/utils/messageListHelpers.ts
@@ -1,3 +1,4 @@
+import { hasAnsweredQuestionToolCall } from "renderer/components/Chat/ChatInterface/utils/messageHelpers";
 import type { ToolPart } from "renderer/components/Chat/ChatInterface/utils/tool-helpers";
 import { normalizeToolName } from "renderer/components/Chat/ChatInterface/utils/tool-helpers";
 import type {
@@ -100,7 +101,10 @@ export function getVisibleMessages({
 	const previousTurns = messages.slice(0, turnStartIndex);
 	const activeTurnNonAssistant = messages
 		.slice(turnStartIndex)
-		.filter((message) => message.role !== "assistant");
+		.filter(
+			(message) =>
+				message.role !== "assistant" || hasAnsweredQuestionToolCall(message),
+		);
 
 	return [...previousTurns, ...activeTurnNonAssistant];
 }

From db77831dde2c5d09010f7d10043ac222922a5b21 Mon Sep 17 00:00:00 2001
From: Michal Kopanski 
Date: Mon, 30 Mar 2026 08:42:27 -0400
Subject: [PATCH 12/70] =?UTF-8?q?fix(chat):=20refactor=20question=20tool?=
 =?UTF-8?q?=20call=20=E2=80=94=20answer=20bubble,=20skip=20badge,=20plain-?=
 =?UTF-8?q?string=20fallback?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Replace the inline Q&A markdown block with a right-aligned answer bubble
(styled like a user message) shown after the question is answered, and a
"Question skipped" badge when the result carries no answers. Add a fallback
for backends that return a plain string result (result.text / result.answer)
rather than a structured answers map. Remove the isPending spinner from the
ToolCallRow since the overlay in the footer already indicates active state.
---
 .../AskUserQuestionToolCall.tsx               | 94 +++++++++++++------
 .../AssistantMessage/AssistantMessage.tsx     |  1 +
 2 files changed, 66 insertions(+), 29 deletions(-)

diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx
index a46aecba43d..7bba2da8969 100644
--- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx
+++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx
@@ -121,7 +121,6 @@ export function AskUserQuestionToolCall({
 	result,
 	outputObject,
 	nestedResultObject,
-	isStreaming,
 }: AskUserQuestionToolCallProps) {
 	const questions = useMemo(
 		() => toQuestionToolQuestions(args.questions),
@@ -138,45 +137,82 @@ export function AskUserQuestionToolCall({
 		[nestedResultObject?.answers, outputObject?.answers, result.answers],
 	);
 
+	// Fallback for plain-string results: getResult() wraps them as { text: "..." }
+	const answerFallbackText = useMemo(() => {
+		if (typeof result.text === "string" && result.text.trim())
+			return result.text.trim();
+		if (typeof result.answer === "string" && result.answer.trim())
+			return result.answer.trim();
+		return undefined;
+	}, [result.text, result.answer]);
+
 	const isPending =
 		part.state !== "output-available" && part.state !== "output-error";
 	const isError = part.state === "output-error";
+	const hasAnswers =
+		Object.keys(answers).length > 0 || answerFallbackText !== undefined;
+
+	// No args available (tool_result-only path with input: {}) — nothing useful to show
+	if (questions.length === 0 && !isError) return null;
 
-	// Hide while the overlay is actively showing (streaming = question is live)
-	if (isPending && isStreaming !== false) return null;
+	const isAnswered = !isPending && !isError && hasAnswers;
+	// Skipped = pending-but-stopped, or result with no answers
+	const isSkipped = !isPending && !isError && !hasAnswers;
 
 	const description =
 		questions.length > 1
 			? `${questions.length} questions`
 			: (questions[0]?.question ?? undefined);
 
+	const answerTexts = useMemo(
+		() =>
+			questions
+				.map((q) =>
+					findAnswerForQuestion({ answers, questionText: q.question }),
+				)
+				.filter((a): a is string => a !== undefined),
+		[questions, answers],
+	);
+
+	const questionContent =
+		questions.length > 0 ? (
+			
+ {questions.map((q, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: questions don't have unique keys +
+ {q.question} +
+ ))} +
+ ) : undefined; + return ( - - {questions.length > 0 ? ( -
- {questions.map((q, i) => { - const answer = findAnswerForQuestion({ - answers, - questionText: q.question, - }); - return ( - // biome-ignore lint/suspicious/noArrayIndexKey: questions don't have unique keys -
-
{q.question}
- {answer && ( -
{answer}
- )} -
- ); - })} + <> + + {questionContent} + + {isSkipped && ( +
+ + Question skipped + +
+ )} + {isAnswered && (answerTexts.length > 0 || answerFallbackText) && ( +
+
+ {answerTexts.length > 0 + ? answerTexts.join("\n") + : answerFallbackText} +
- ) : undefined} - + )} + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx index 86594cb110a..81fb0a139f7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx @@ -261,6 +261,7 @@ export function AssistantMessage({ sessionId={sessionId} organizationId={organizationId} workspaceCwd={workspaceCwd} + isStreaming={isStreaming} />, ); nodes.push(...getInlineToolStateNodes(part.id)); From 5f947888e535b24ff87a66866d14ea6c36d4aa25 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Mon, 30 Mar 2026 08:42:38 -0400 Subject: [PATCH 13/70] fix(chat): overlay freezes in place on submit, skip sends answer, scroll on question changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fire onRespond without awaiting so the overlay never shows a separate "Waiting for response..." state. The overlay stays frozen (same size and content) until pendingQuestion updates from the server, at which point React remounts the component via key={pendingQuestion.questionId}. - Highlight the chosen option and replace its number badge with a spinner; show a spinner on the pencil icon when the answer came from the text input. - Skip now sends "skip" as the answer instead of aborting the agent, so the LLM can continue. The X button still stops the session. - Fix isQuestionSubmitting hardcoded to false — now passes questionResponsePending. - Add footerScrollTrigger so the chat scrolls to the bottom whenever the question overlay appears, updates, or disappears. --- .../QuestionInputOverlay.tsx | 123 +++++++++++------- .../ChatPaneInterface.tsx | 19 ++- .../ChatInputFooter/ChatInputFooter.tsx | 1 + .../ChatMessageList/ChatMessageList.tsx | 19 ++- .../ChatMessageList/ChatMessageList.types.ts | 1 + 5 files changed, 111 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx index 8090bb79a5a..46bcf002d02 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx @@ -1,6 +1,6 @@ import { cn } from "@superset/ui/lib/utils"; -import { ArrowUpIcon, PencilIcon, XIcon } from "lucide-react"; -import { useRef, useState } from "react"; +import { ArrowUpIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; type QuestionOption = { label: string; description?: string }; @@ -22,34 +22,40 @@ export function QuestionInputOverlay({ onCancel, }: QuestionInputOverlayProps) { const [customText, setCustomText] = useState(""); - const [activeLabel, setActiveLabel] = useState(null); + // Tracks which label was submitted: an option label, "__custom__", or "__skip__". + // null = nothing submitted yet. + const [submittedLabel, setSubmittedLabel] = useState(null); const inputRef = useRef(null); - const options = question.options ?? []; - const isDisabled = isSubmitting || Boolean(activeLabel); + useEffect(() => { + setSubmittedLabel(null); + setCustomText(""); + }, [question.questionId, question.question]); + + const options = question.options ?? []; + const submitted = submittedLabel !== null; + const isDisabled = isSubmitting || submitted; const hasCustomText = customText.trim().length > 0; + // Spinner goes on the pencil icon when the answer came from the text input row. + const isInputRowSubmitted = + submitted && !options.some((o) => o.label === submittedLabel); - const handleOption = async (label: string) => { + const handleSubmitAnswer = (answer: string, label: string) => { if (isDisabled) return; - setActiveLabel(label); - try { - await onRespond(question.questionId, label); - } catch { - setActiveLabel(null); - } + setSubmittedLabel(label); + onRespond(question.questionId, answer).catch(() => { + setSubmittedLabel(null); + setCustomText(""); + }); }; - const handleCustom = async () => { + const handleOption = (label: string) => handleSubmitAnswer(label, label); + const handleCustom = () => { const trimmed = customText.trim(); - if (!trimmed || isDisabled) return; - setActiveLabel(trimmed); - try { - await onRespond(question.questionId, trimmed); - } catch { - setActiveLabel(null); - setCustomText(""); - } + if (!trimmed) return; + handleSubmitAnswer(trimmed, "__custom__"); }; + const handleSkip = () => handleSubmitAnswer("skip", "__skip__"); return (
@@ -73,47 +79,63 @@ export function QuestionInputOverlay({
- {options.map((option, i) => ( -
- -
- ))} + {options.map((option, i) => { + const isChosen = submittedLabel === option.label; + return ( +
+ +
+ ); + })}
)} - {/* Something else */} + {/* Text input / skip */} {/* biome-ignore lint/a11y/useKeyWithClickEvents: click-to-focus affordance */}
{ e.preventDefault(); - void handleCustom(); + handleCustom(); }} onClick={() => inputRef.current?.focus()} > - + {isInputRowSubmitted ? ( + + ) : ( + + )} { e.stopPropagation(); onCancel(); }} + onClick={(e) => { + e.stopPropagation(); + handleSkip(); + }} > Skip diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx index 36b00a7d8cb..d27cb6e8979 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx @@ -226,6 +226,11 @@ export function ChatPaneInterface({ const [approvalResponsePending, setApprovalResponsePending] = useState(false); const [planResponsePending, setPlanResponsePending] = useState(false); const [questionResponsePending, setQuestionResponsePending] = useState(false); + const [footerScrollTrigger, setFooterScrollTrigger] = useState(0); + const bumpFooterScroll = useCallback( + () => setFooterScrollTrigger((n) => n + 1), + [], + ); const [editingUserMessageId, setEditingUserMessageId] = useState< string | null >(null); @@ -496,6 +501,11 @@ export function ChatPaneInterface({ setSubmitStatus(undefined); }, [isRunning]); + // Scroll chat to bottom whenever the footer question overlay appears, changes, or disappears + useEffect(() => { + bumpFooterScroll(); + }, [pendingQuestion?.questionId, pendingQuestion?.question, bumpFooterScroll]); + useEffect(() => { onRawSnapshotChange?.({ sessionId, @@ -906,6 +916,7 @@ export function ChatPaneInterface({ const trimmedAnswer = answer.trim(); if (!trimmedQuestionId || !trimmedAnswer) return; clearRuntimeError(); + bumpFooterScroll(); setQuestionResponsePending(true); try { await commands.respondToQuestion({ @@ -918,7 +929,7 @@ export function ChatPaneInterface({ setQuestionResponsePending(false); } }, - [clearRuntimeError, commands], + [bumpFooterScroll, clearRuntimeError, commands], ); const errorMessage = runtimeError ?? toErrorMessage(error); @@ -953,6 +964,7 @@ export function ChatPaneInterface({ onCancelEditUserMessage={() => setEditingUserMessageId(null)} onSubmitEditedUserMessage={handleSubmitEditedUserMessage} onRestartUserMessage={handleResendUserMessage} + footerScrollTrigger={footerScrollTrigger} /> void stopActiveResponse()} + onQuestionCancel={() => { + bumpFooterScroll(); + void stopActiveResponse(); + }} />
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx index 0c128cf35f7..8986fdbc4a4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx @@ -148,6 +148,7 @@ export function ChatInputFooter({ )} {pendingQuestion && onQuestionRespond && onQuestionCancel ? ( { + if (isAtBottomRef.current) { + scrollToBottom("instant"); + } + }, [trigger, scrollToBottom]); + + return null; +} + export function ChatMessageList({ messages, isFocused, @@ -56,6 +71,7 @@ export function ChatMessageList({ onCancelEditUserMessage, onSubmitEditedUserMessage, onRestartUserMessage, + footerScrollTrigger = 0, }: ChatMessageListProps) { const messageListRef = useRef(null); const chatSearch = useChatMessageSearch({ @@ -273,6 +289,7 @@ export function ChatMessageList({ onFindPrevious={chatSearch.findPrevious} onClose={chatSearch.closeSearch} /> + diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.types.ts index 45891f9dd39..7ffb9838953 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.types.ts @@ -86,4 +86,5 @@ export interface ChatMessageListProps { request: UserMessageRestartRequest, ) => Promise; onRestartUserMessage: (request: UserMessageRestartRequest) => Promise; + footerScrollTrigger?: number; } From 1f646a90f70f7a46d86447c7e9b5b9e69968c3eb Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Mon, 30 Mar 2026 10:52:01 -0400 Subject: [PATCH 14/70] feat(chat): question overlay max-height with scrollable options and pinned header/footer --- .../QuestionInputOverlay/QuestionInputOverlay.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx index 46bcf002d02..d09a8b894a2 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx @@ -58,9 +58,9 @@ export function QuestionInputOverlay({ const handleSkip = () => handleSubmitAnswer("skip", "__skip__"); return ( -
- {/* Question */} -
+
+ {/* Question — pinned header */} +

{question.question}

@@ -74,11 +74,11 @@ export function QuestionInputOverlay({
- {/* Options */} + {/* Options — scrollable */} {options.length > 0 && (
@@ -120,10 +120,10 @@ export function QuestionInputOverlay({
)} - {/* Text input / skip */} + {/* Text input / skip — pinned footer */} {/* biome-ignore lint/a11y/useKeyWithClickEvents: click-to-focus affordance */} { e.preventDefault(); handleCustom(); From 5318d6fcdea1d787aecef148145aa42cb4ffb933 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Mon, 30 Mar 2026 10:52:23 -0400 Subject: [PATCH 15/70] fix(chat): stabilize useFocusPromptOnPane effect dependency --- .../Chat/ChatInterface/hooks/useFocusPromptOnPane.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/hooks/useFocusPromptOnPane.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/hooks/useFocusPromptOnPane.ts index bac0383f03d..f5444622569 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/hooks/useFocusPromptOnPane.ts +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/hooks/useFocusPromptOnPane.ts @@ -3,11 +3,11 @@ import { useEffect } from "react"; export function useFocusPromptOnPane(isFocused: boolean) { const { textInput } = usePromptInputController(); - const focusPrompt = textInput.focus; + const { focus } = textInput; useEffect(() => { if (isFocused) { - focusPrompt(); + focus(); } - }, [focusPrompt, isFocused]); + }, [isFocused, focus]); } From cce5656766ae37db3bff51b4e1c70c24cdf932a2 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Mon, 30 Mar 2026 13:38:06 -0400 Subject: [PATCH 16/70] feat(chat): show question tool call with status and collapsible answer Rewrites AskUserQuestionToolCall to use the shared ToolCallRow component. - Supports both ask_user (singular question/options) and ask_user_question (array of questions) tool schemas - Shows AWAITING RESPONSE / ANSWERED / CANCELLED inline status description - Expands to reveal the question text + submitted answer when answered - Extracts answer from result.content "User answered: " format - ToolCallRow now uses cursor-text when the row has no expandable content --- .../AskUserQuestionToolCall.tsx | 152 +++++++++++------- .../components/ai-elements/tool-call-row.tsx | 4 +- 2 files changed, 100 insertions(+), 56 deletions(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx index 7bba2da8969..45faab7f07e 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx @@ -1,5 +1,5 @@ import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; -import { MessageCircleQuestionIcon } from "lucide-react"; +import { CheckIcon, ClockIcon, MessageCircleQuestionIcon, XIcon } from "lucide-react"; import { useMemo } from "react"; import type { ToolPart } from "../../../../utils/tool-helpers"; @@ -115,6 +115,49 @@ function findAnswerForQuestion({ return undefined; } +type QuestionStatus = "awaiting" | "answered" | "cancelled"; + +const QUESTION_STATUS_CONFIG: Record< + QuestionStatus, + { label: string; icon: typeof ClockIcon } +> = { + awaiting: { label: "Awaiting Response", icon: ClockIcon }, + answered: { label: "Answered", icon: CheckIcon }, + cancelled: { label: "Cancelled", icon: XIcon }, +}; + +function QuestionStatusDescription({ status }: { status: QuestionStatus }) { + const { label, icon: Icon } = QUESTION_STATUS_CONFIG[status]; + return ( + + + {label} + + ); +} + +function toSingleQuestion(args: Record): QuestionToolQuestion[] { + const question = + typeof args.question === "string" ? args.question.trim() : ""; + if (!question) return []; + + const options = Array.isArray(args.options) + ? args.options + .map((opt): QuestionToolOption | null => { + if (typeof opt !== "object" || opt === null) return null; + const o = opt as Record; + const label = typeof o.label === "string" ? o.label.trim() : ""; + if (!label) return null; + const description = + typeof o.description === "string" ? o.description.trim() : ""; + return description ? { label, description } : { label }; + }) + .filter((o): o is QuestionToolOption => o !== null) + : []; + + return [{ question, options }]; +} + export function AskUserQuestionToolCall({ part, args, @@ -123,8 +166,11 @@ export function AskUserQuestionToolCall({ nestedResultObject, }: AskUserQuestionToolCallProps) { const questions = useMemo( - () => toQuestionToolQuestions(args.questions), - [args.questions], + () => + Array.isArray(args.questions) + ? toQuestionToolQuestions(args.questions) + : toSingleQuestion(args), + [args], ); const answers = useMemo( @@ -137,14 +183,20 @@ export function AskUserQuestionToolCall({ [nestedResultObject?.answers, outputObject?.answers, result.answers], ); - // Fallback for plain-string results: getResult() wraps them as { text: "..." } + // Fallback for plain-string results and mastracode's { content: "User answered: " } format const answerFallbackText = useMemo(() => { if (typeof result.text === "string" && result.text.trim()) return result.text.trim(); if (typeof result.answer === "string" && result.answer.trim()) return result.answer.trim(); + // ask_user tool returns { content: "User answered: ", isError: false } + if (typeof result.content === "string" && result.content.trim()) { + const raw = result.content.trim(); + const prefix = "User answered: "; + return raw.startsWith(prefix) ? raw.slice(prefix.length).trim() : raw; + } return undefined; - }, [result.text, result.answer]); + }, [result.text, result.answer, result.content]); const isPending = part.state !== "output-available" && part.state !== "output-error"; @@ -156,63 +208,53 @@ export function AskUserQuestionToolCall({ if (questions.length === 0 && !isError) return null; const isAnswered = !isPending && !isError && hasAnswers; - // Skipped = pending-but-stopped, or result with no answers - const isSkipped = !isPending && !isError && !hasAnswers; - - const description = - questions.length > 1 - ? `${questions.length} questions` - : (questions[0]?.question ?? undefined); + const isCancelled = !isPending && !isError && !hasAnswers; - const answerTexts = useMemo( + const answeredQAs = useMemo( () => questions - .map((q) => - findAnswerForQuestion({ answers, questionText: q.question }), - ) - .filter((a): a is string => a !== undefined), + .map((q) => ({ + question: q.question, + answer: findAnswerForQuestion({ answers, questionText: q.question }), + })) + .filter((qa): qa is { question: string; answer: string } => + qa.answer !== undefined, + ), [questions, answers], ); - const questionContent = - questions.length > 0 ? ( -
- {questions.map((q, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: questions don't have unique keys -
- {q.question} -
- ))} -
- ) : undefined; + // Fallback for plain-string result when questions array has one entry + const fallbackQA = + answeredQAs.length === 0 && answerFallbackText && questions[0] + ? { question: questions[0].question, answer: answerFallbackText } + : null; + + const qasToShow = answeredQAs.length > 0 ? answeredQAs : fallbackQA ? [fallbackQA] : []; return ( - <> - - {questionContent} - - {isSkipped && ( -
- - Question skipped - -
- )} - {isAnswered && (answerTexts.length > 0 || answerFallbackText) && ( -
-
- {answerTexts.length > 0 - ? answerTexts.join("\n") - : answerFallbackText} -
-
- )} - + + ) : isAnswered ? ( + + ) : isCancelled ? ( + + ) : undefined + } + > + {isAnswered && qasToShow.length > 0 + ? qasToShow.map((qa) => ( +
+
{qa.question}
+
{qa.answer}
+
+ )) + : undefined} +
); } diff --git a/packages/ui/src/components/ai-elements/tool-call-row.tsx b/packages/ui/src/components/ai-elements/tool-call-row.tsx index 19cec07ac5f..ee3a7a62f6e 100644 --- a/packages/ui/src/components/ai-elements/tool-call-row.tsx +++ b/packages/ui/src/components/ai-elements/tool-call-row.tsx @@ -88,7 +88,9 @@ export function ToolCallRow({
)) + : isCancelledByError && questions.length > 0 + ? questions.map((q) => ( +
+
{q.question}
+
+ + Aborted by the user +
+
+ )) : undefined} ); From ac8890103c3e9ff64e13cfe004e723e3563a536f Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Mon, 30 Mar 2026 14:41:59 -0400 Subject: [PATCH 21/70] fix(chat): keep bottom-pinned scroll when expanding a tool call When the chat is pinned to the bottom and the user opens a collapsible tool call, skip stopScroll() so stick-to-bottom's resize handler auto-scrolls to reveal the expanded content instead of hiding it behind the prompt input. --- .../ui/src/components/ai-elements/conversation.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/ai-elements/conversation.tsx b/packages/ui/src/components/ai-elements/conversation.tsx index 0ec29136468..57030f3a5c0 100644 --- a/packages/ui/src/components/ai-elements/conversation.tsx +++ b/packages/ui/src/components/ai-elements/conversation.tsx @@ -28,15 +28,21 @@ export const ConversationContent = ({ className, ...props }: ConversationContentProps) => { - const { stopScroll } = useStickToBottomContext(); + const { isAtBottom, stopScroll } = useStickToBottomContext(); const handleMouseDown = useCallback( (e: React.MouseEvent) => { - if ((e.target as Element).closest("[data-tool-trigger]")) { - stopScroll(); + const toolTrigger = (e.target as Element).closest("[data-tool-trigger]"); + if (toolTrigger) { + const isOpening = toolTrigger.getAttribute("data-state") === "closed"; + // If pinned to the bottom and about to expand, don't unpin — let + // stick-to-bottom's resize handler auto-scroll to reveal the new content. + if (!isAtBottom || !isOpening) { + stopScroll(); + } } }, - [stopScroll], + [isAtBottom, stopScroll], ); return ( From 475bf66fc6c60b10592ba506a305e5d0c4e7d1b6 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Mon, 30 Mar 2026 14:47:25 -0400 Subject: [PATCH 22/70] fix(chat): exclude scrollbar column from input footer gradient --- .../components/ChatInputFooter/ChatInputFooter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx index 3ba47fa2327..c896012f16c 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx @@ -128,7 +128,7 @@ export function ChatInputFooter({ ); return ( - + {(dragType) => (
{errorMessage && ( From 7d004307e07c9babe1e336137660df803bee197e Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Mon, 30 Mar 2026 15:33:05 -0400 Subject: [PATCH 23/70] fix(chat): stop scroll jump when expanding any tool call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the overly-broad "scroll to bottom if last trigger" heuristic that used a DOM query to find the last [data-tool-trigger] in the container. This was wrong — it would fire even when there were messages below the tool call being expanded. Now ConversationContent simply unpins from bottom on any tool trigger click, preventing the resize handler from jumping the scroll position. Nothing more. --- .../src/components/ai-elements/conversation.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/ai-elements/conversation.tsx b/packages/ui/src/components/ai-elements/conversation.tsx index 57030f3a5c0..a470ddffa2a 100644 --- a/packages/ui/src/components/ai-elements/conversation.tsx +++ b/packages/ui/src/components/ai-elements/conversation.tsx @@ -28,21 +28,16 @@ export const ConversationContent = ({ className, ...props }: ConversationContentProps) => { - const { isAtBottom, stopScroll } = useStickToBottomContext(); + const { stopScroll } = useStickToBottomContext(); const handleMouseDown = useCallback( (e: React.MouseEvent) => { - const toolTrigger = (e.target as Element).closest("[data-tool-trigger]"); - if (toolTrigger) { - const isOpening = toolTrigger.getAttribute("data-state") === "closed"; - // If pinned to the bottom and about to expand, don't unpin — let - // stick-to-bottom's resize handler auto-scroll to reveal the new content. - if (!isAtBottom || !isOpening) { - stopScroll(); - } + if ((e.target as Element).closest("[data-tool-trigger]")) { + // Unpin from bottom so the resize handler never jumps the scroll position. + stopScroll(); } }, - [isAtBottom, stopScroll], + [stopScroll], ); return ( From 5675dc914b92dd8d5df61a90b5dbe115db60647d Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Mon, 30 Mar 2026 16:15:42 -0400 Subject: [PATCH 24/70] feat(chat): always use ask_user tool for questions in Superset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-pronged approach to ensure the LLM never asks questions as plain text (which bypasses the question overlay) and always uses the ask_user tool: 1. AGENTS.md — adds a project-level override rule that loads into the mastracode system prompt for any session in the Superset workspace. 2. host-service — writes a managed ~/.mastracode/AGENTS.md with the same rule, applied globally to every workspace opened in the desktop app. Uses a managed-by marker to avoid overwriting user-authored files. Root cause: the default mastracode tool guidance says "Don't use this for simple yes/no — just ask in your text response." These rules override that by being appended to the system prompt after the base instructions. --- AGENTS.md | 4 +++ .../host-service/src/runtime/chat/chat.ts | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 960a41b568d..5676d46e81c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,9 @@ # Superset Monorepo Guide +## Question Tool + +When you need to ask the user ANY question — including simple yes/no, confirmations, and clarifications — ALWAYS use the `ask_user` tool. Never ask questions in plain text. The Superset UI renders `ask_user` calls as an interactive overlay with clickable option buttons; plain-text questions will not be surfaced to the user in the same way. + Guidelines for agents and developers working in this repository. ## Structure diff --git a/packages/host-service/src/runtime/chat/chat.ts b/packages/host-service/src/runtime/chat/chat.ts index 8fca5e6ffb7..a25c253ecb6 100644 --- a/packages/host-service/src/runtime/chat/chat.ts +++ b/packages/host-service/src/runtime/chat/chat.ts @@ -1,3 +1,6 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { eq } from "drizzle-orm"; import { createMastraCode } from "mastracode"; import type { HostDb } from "../../db"; @@ -348,6 +351,35 @@ export class ChatRuntimeManager { }); } + /** + * Ensures ~/.mastracode/AGENTS.md exists with Superset-specific instructions. + * Only writes when the file is absent or was previously written by us (identified + * by the managed-by marker). Skips silently on any filesystem error. + */ + private ensureGlobalAgentInstructions(): void { + const MANAGED_MARKER = ""; + const INSTRUCTIONS = `${MANAGED_MARKER} +## Question Tool + +When you need to ask the user ANY question — including simple yes/no, confirmations, and clarifications — ALWAYS use the \`ask_user\` tool. Never ask questions in plain text. The Superset UI renders \`ask_user\` calls as an interactive overlay with clickable option buttons; plain-text questions will not be surfaced to the user in the same way. +`; + try { + const dir = join(homedir(), ".mastracode"); + const filePath = join(dir, "AGENTS.md"); + if (existsSync(filePath)) { + const existing = readFileSync(filePath, "utf-8"); + if (!existing.includes(MANAGED_MARKER)) { + // User-managed file — don't overwrite + return; + } + } + mkdirSync(dir, { recursive: true }); + writeFileSync(filePath, INSTRUCTIONS, "utf-8"); + } catch { + // Non-fatal — instructions enhancement is best-effort + } + } + private async createRuntime( sessionId: string, workspaceId: string, @@ -366,6 +398,7 @@ export class ChatRuntimeManager { const cwd = workspace.worktreePath; + this.ensureGlobalAgentInstructions(); await this.runtimeResolver.prepareRuntimeEnv(); const runtime = await createMastraCode({ From 7ee9874cae90e012c213b561da6ca25dfe2cddfc Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Mon, 30 Mar 2026 17:28:47 -0400 Subject: [PATCH 25/70] feat(chat): pending question drives workspace nav status and native notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - When ask_user tool fires, emit PendingQuestion lifecycle event from the harness subscriber (same pipeline as PermissionRequest/Start/Stop) - useAgentHookListener maps PendingQuestion → "permission" pane status, showing the orange dot in the workspace nav immediately, even when the tab is not focused - NotificationManager plays sound and shows "Awaiting Response" native toast for PendingQuestion events with visibility suppression - Cancel tooltip added to QuestionInputOverlay X button - Cancelled question tool calls now show question text + "Aborted by the user" immediately on stop, without requiring a page reload - isInterrupted prop threaded through MessagePartsRenderer → ToolCallBlock → AskUserQuestionToolCall so pending questions show CANCELLED status when the run is interrupted - INTERRUPTED badge is always shown alongside cancelled question state --- .../lib/notifications/notification-manager.ts | 15 ++++---- apps/desktop/src/main/windows/main.ts | 1 + .../QuestionInputOverlay.tsx | 26 +++++++++----- .../components/MessageList/MessageList.tsx | 36 ++++++++++--------- .../MessagePartsRenderer.tsx | 3 ++ .../ToolCallBlock/ToolCallBlock.tsx | 3 ++ .../AskUserQuestionToolCall.tsx | 23 ++++++++---- .../ChatMessageList/ChatMessageList.tsx | 18 ++++++++++ .../AssistantMessage/AssistantMessage.tsx | 4 +++ .../stores/tabs/useAgentHookListener.ts | 5 ++- apps/desktop/src/shared/notification-types.ts | 2 +- .../src/server/trpc/utils/runtime/runtime.ts | 19 +++++++++- 12 files changed, 115 insertions(+), 40 deletions(-) diff --git a/apps/desktop/src/main/lib/notifications/notification-manager.ts b/apps/desktop/src/main/lib/notifications/notification-manager.ts index 0ad7facd07b..3434b9ee84a 100644 --- a/apps/desktop/src/main/lib/notifications/notification-manager.ts +++ b/apps/desktop/src/main/lib/notifications/notification-manager.ts @@ -64,13 +64,16 @@ export class NotificationManager { const title = this.deps.getNotificationTitle(event); const isPermissionRequest = event.eventType === "PermissionRequest"; + const isPendingQuestion = event.eventType === "PendingQuestion"; const notification = this.deps.createNotification({ - title: isPermissionRequest - ? `Input Needed — ${workspaceName}` - : `Agent Complete — ${workspaceName}`, - body: isPermissionRequest - ? `"${title}" needs your attention` - : `"${title}" has finished its task`, + title: + isPermissionRequest || isPendingQuestion + ? `Awaiting Response — ${workspaceName}` + : `Agent Complete — ${workspaceName}`, + body: + isPermissionRequest || isPendingQuestion + ? `"${title}" is waiting for your reply` + : `"${title}" has finished its task`, silent: true, }); diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 4867b91d227..7adab3cba30 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -188,6 +188,7 @@ export async function MainWindow() { }, ); + // Forward low-volume terminal lifecycle events to the renderer via the existing // notifications subscription. This is used only for correctness (e.g. clearing // stuck agent lifecycle statuses when terminal panes aren't mounted). diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx index d09a8b894a2..06581a33d78 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx @@ -1,4 +1,9 @@ import { cn } from "@superset/ui/lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@superset/ui/tooltip"; import { ArrowUpIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -64,14 +69,19 @@ export function QuestionInputOverlay({

{question.question}

- + + + + + Cancel +
{/* Options — scrollable */} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx index 944f70c149c..4ccc872d72f 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx @@ -85,16 +85,19 @@ export function MessageList({ const toolPart = part as ToolPart; const toolName = normalizeToolName(getToolName(toolPart)); if (toolName !== "ask_user_question") return false; + // Pending (awaiting response when run was stopped) + if ( + toolPart.state !== "output-available" && + toolPart.state !== "output-error" + ) + return true; if (toolPart.state === "output-error") return true; - if (toolPart.state === "output-available") { - const out = toolPart.output; - return ( - typeof out === "object" && - out !== null && - (out as Record).isError === true - ); - } - return false; + const out = toolPart.output; + return ( + typeof out === "object" && + out !== null && + (out as Record).isError === true + ); }) ?? false; return ( @@ -260,18 +263,17 @@ export function MessageList({ parts={interruptedMessage.parts} isLastAssistant={false} isStreaming={false} + isInterrupted workspaceId={workspaceId} workspaceCwd={workspaceCwd} onAnswer={onAnswer} /> - {!interruptedByAbortedQuestion && ( -
- - Interrupted - - Response stopped -
- )} +
+ + Interrupted + + Response stopped +
)} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessagePartsRenderer/MessagePartsRenderer.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessagePartsRenderer/MessagePartsRenderer.tsx index f7380ce1a10..8bed1e7e06f 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessagePartsRenderer/MessagePartsRenderer.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessagePartsRenderer/MessagePartsRenderer.tsx @@ -29,6 +29,7 @@ interface MessagePartsRendererProps { parts: UIMessage["parts"]; isLastAssistant: boolean; isStreaming: boolean; + isInterrupted?: boolean; workspaceId?: string; workspaceCwd?: string; onAnswer?: ( @@ -41,6 +42,7 @@ export function MessagePartsRenderer({ parts, isLastAssistant, isStreaming, + isInterrupted, workspaceId, workspaceCwd, onAnswer, @@ -308,6 +310,7 @@ export function MessagePartsRenderer({ , @@ -70,6 +71,7 @@ export function ToolCallBlock({ sessionId, organizationId, isStreaming, + isInterrupted, onAnswer, }: ToolCallBlockProps) { const args = getArgs(part); @@ -496,6 +498,7 @@ export function ToolCallBlock({ outputObject={outputObject} nestedResultObject={nestedResultObject} isStreaming={isStreaming} + isInterrupted={isInterrupted} onAnswer={onAnswer} /> ); diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx index 77823ee6121..d869cf19a15 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx @@ -22,6 +22,7 @@ interface AskUserQuestionToolCallProps { outputObject?: Record; nestedResultObject?: Record; isStreaming?: boolean; + isInterrupted?: boolean; onAnswer?: ( toolCallId: string, answers: Record, @@ -164,6 +165,7 @@ export function AskUserQuestionToolCall({ result, outputObject, nestedResultObject, + isInterrupted, }: AskUserQuestionToolCallProps) { const questions = useMemo( () => @@ -203,17 +205,26 @@ export function AskUserQuestionToolCall({ return undefined; }, [isResultError, result.text, result.answer, result.content]); + const isCancelledByStop = + !!isInterrupted && + part.state !== "output-available" && + part.state !== "output-error"; const isPending = - part.state !== "output-available" && part.state !== "output-error"; + !isCancelledByStop && + part.state !== "output-available" && + part.state !== "output-error"; const isCancelledByError = part.state === "output-error" || isResultError; const hasAnswers = Object.keys(answers).length > 0 || answerFallbackText !== undefined; // No args available (tool_result-only path with input: {}) — nothing useful to show - if (questions.length === 0 && !isCancelledByError) return null; + if (questions.length === 0 && !isCancelledByError && !isCancelledByStop) + return null; - const isAnswered = !isPending && !isCancelledByError && hasAnswers; - const isCancelled = !isPending && !isCancelledByError && !hasAnswers; + const isAnswered = + !isPending && !isCancelledByError && !isCancelledByStop && hasAnswers; + const isCancelled = + !isPending && !isCancelledByError && !isCancelledByStop && !hasAnswers; const answeredQAs = useMemo( () => @@ -247,7 +258,7 @@ export function AskUserQuestionToolCall({ ) : isAnswered ? ( - ) : (isCancelled || isCancelledByError) ? ( + ) : (isCancelled || isCancelledByError || isCancelledByStop) ? ( ) : undefined } @@ -259,7 +270,7 @@ export function AskUserQuestionToolCall({
{qa.answer}
)) - : isCancelledByError && questions.length > 0 + : (isCancelledByError || isCancelledByStop) && questions.length > 0 ? questions.map((q) => (
{q.question}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.tsx index 90ba36def97..0c8bac433b4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.tsx @@ -30,6 +30,7 @@ import { removeInterruptedSourceMessage, resolvePendingPlanToolCallId, } from "./utils/messageListHelpers"; +import { normalizeToolName } from "renderer/components/Chat/ChatInterface/utils/tool-helpers"; function ScrollAnchor({ questionId, @@ -166,6 +167,22 @@ export function ChatMessageList({ renderedMessages, ]); + const interruptedByAbortedQuestion = useMemo(() => { + if (!interruptedPreview) return false; + const content = interruptedPreview.content; + const answeredIds = new Set( + content + .filter((p) => p.type === "tool_result") + .map((p) => p.id), + ); + return content.some( + (p) => + p.type === "tool_call" && + normalizeToolName(p.name) === "ask_user_question" && + !answeredIds.has(p.id), + ); + }, [interruptedPreview]); + const shouldShowStandalonePendingPlan = Boolean( pendingPlanApproval && !pendingPlanToolCallId, ); @@ -255,6 +272,7 @@ export function ChatMessageList({ organizationId={organizationId} workspaceCwd={workspaceCwd} isStreaming={false} + isInterrupted previewToolParts={[]} {...inlineToolStateProps} footer={} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx index 81a19a53d03..f92535d5b39 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx @@ -21,6 +21,7 @@ type ChatPendingPlanApproval = UseChatDisplayReturn["pendingPlanApproval"]; interface AssistantMessageProps { message: ChatMessage; isStreaming: boolean; + isInterrupted?: boolean; workspaceId: string; sessionId?: string | null; organizationId?: string | null; @@ -101,6 +102,7 @@ function toToolPartFromResult(part: ChatToolResult): ToolPart { export function AssistantMessage({ message, isStreaming, + isInterrupted, workspaceId, sessionId, organizationId, @@ -262,6 +264,7 @@ export function AssistantMessage({ organizationId={organizationId} workspaceCwd={workspaceCwd} isStreaming={isStreaming} + isInterrupted={isInterrupted} />, ); nodes.push(...getInlineToolStateNodes(part.id)); @@ -286,6 +289,7 @@ export function AssistantMessage({ organizationId={organizationId} workspaceCwd={workspaceCwd} isStreaming={isStreaming} + isInterrupted={isInterrupted} />, ); nodes.push(...getInlineToolStateNodes(part.id)); diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index fcde2bf4ccd..281fcec69e5 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -64,7 +64,10 @@ export function useAgentHookListener() { if (eventType === "Start") { state.setPaneStatus(paneId, "working"); - } else if (eventType === "PermissionRequest") { + } else if ( + eventType === "PermissionRequest" || + eventType === "PendingQuestion" + ) { state.setPaneStatus(paneId, "permission"); } else if (eventType === "Stop") { const activeTabId = state.activeTabIds[workspaceId]; diff --git a/apps/desktop/src/shared/notification-types.ts b/apps/desktop/src/shared/notification-types.ts index 6bf29f58450..49b5347768e 100644 --- a/apps/desktop/src/shared/notification-types.ts +++ b/apps/desktop/src/shared/notification-types.ts @@ -11,5 +11,5 @@ export interface NotificationIds { } export interface AgentLifecycleEvent extends NotificationIds { - eventType: "Start" | "Stop" | "PermissionRequest"; + eventType: "Start" | "Stop" | "PermissionRequest" | "PendingQuestion"; } diff --git a/packages/chat/src/server/trpc/utils/runtime/runtime.ts b/packages/chat/src/server/trpc/utils/runtime/runtime.ts index 8395ceffa70..905ec9ef8ef 100644 --- a/packages/chat/src/server/trpc/utils/runtime/runtime.ts +++ b/packages/chat/src/server/trpc/utils/runtime/runtime.ts @@ -175,7 +175,7 @@ export async function destroyRuntime(runtime: RuntimeSession): Promise { export interface LifecycleEvent { sessionId: string; - eventType: "Start" | "Stop" | "PermissionRequest"; + eventType: "Start" | "Stop" | "PermissionRequest" | "PendingQuestion"; } /** @@ -202,6 +202,13 @@ export function subscribeToSessionEvents( runtime.lastErrorMessage = toRuntimeErrorMessage(event.error); return; } + if (isHarnessAskQuestionEvent(event)) { + onLifecycleEvent?.({ + sessionId: runtime.sessionId, + eventType: "PendingQuestion", + }); + return; + } if (isHarnessSandboxAccessRequestEvent(event)) { runtime.pendingSandboxQuestion = { questionId: event.questionId, @@ -285,6 +292,16 @@ function isHarnessSandboxAccessRequestEvent(event: unknown): event is { ); } +function isHarnessAskQuestionEvent( + event: unknown, +): event is { type: "ask_question"; questionId: string } { + return ( + isObjectRecord(event) && + event.type === "ask_question" && + typeof event.questionId === "string" + ); +} + function isHarnessThreadChangedEvent( event: unknown, ): event is { type: "thread_changed"; threadId: string } { From 9e376ca315e9ec198e4b0a97304ac963b9d91b75 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Mon, 30 Mar 2026 18:05:01 -0400 Subject: [PATCH 26/70] fix(chat): clear orange dot on answer submit, focus prompt on dismiss, no green dot when tab is focused - Clear pane status to idle immediately when user submits a question answer - Focus prompt textarea when question overlay dismisses (rAF to let overlay unmount and browser focus settle) - Fix Stop event idle/review determination: read URL from hash (not pathname, which is always the file path in hash-routed Electron app), and add focusedPaneIds as a reliable fallback so panes the user is actively interacting with don't receive a spurious green dot - Remove MarkdownToggleContent in favour of always-on MessageResponse for subagent output --- .../ChatInputFooter/ChatInputFooter.tsx | 18 ++++++- .../SubagentToolCall/SubagentToolCall.tsx | 23 ++++---- .../MarkdownToggleContent.tsx | 52 ------------------- .../components/MarkdownToggleContent/index.ts | 1 - .../SubagentExecutionMessage.tsx | 30 ++++------- .../ChatPaneInterface/ChatPaneInterface.tsx | 2 + .../SubagentExecutionMessage.tsx | 30 ++++------- .../stores/tabs/useAgentHookListener.ts | 46 +++++++++++----- 8 files changed, 84 insertions(+), 118 deletions(-) delete mode 100644 apps/desktop/src/renderer/components/Chat/components/MarkdownToggleContent/MarkdownToggleContent.tsx delete mode 100644 apps/desktop/src/renderer/components/Chat/components/MarkdownToggleContent/index.ts diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx index c896012f16c..828d3a782b9 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx @@ -4,12 +4,13 @@ import { PromptInputAttachments, type PromptInputMessage, PromptInputTextarea, + usePromptInputController, } from "@superset/ui/ai-elements/prompt-input"; import type { ThinkingLevel } from "@superset/ui/ai-elements/thinking-toggle"; import type { ChatStatus, FileUIPart } from "ai"; import type React from "react"; import type { ReactNode } from "react"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useFocusPromptOnPane } from "renderer/components/Chat/ChatInterface/hooks/useFocusPromptOnPane"; import { useHotkeyDisplay } from "renderer/hotkeys"; import type { SlashCommand } from "../../hooks/useSlashCommands"; @@ -89,6 +90,21 @@ export function ChatInputFooter({ onQuestionCancel, }: ChatInputFooterProps) { useFocusPromptOnPane(isFocused); + + // Focus the prompt when the question overlay dismisses (pendingQuestion → null). + // Uses rAF so the textarea has time to mount, register its ref, and browser + // focus-stealing from the unmounting overlay has settled. + const { textInput } = usePromptInputController(); + const prevPendingQuestionRef = useRef(pendingQuestion); + useEffect(() => { + const prev = prevPendingQuestionRef.current; + prevPendingQuestionRef.current = pendingQuestion; + if (prev != null && pendingQuestion == null) { + const id = requestAnimationFrame(() => textInput.focus()); + return () => cancelAnimationFrame(id); + } + }, [pendingQuestion, textInput]); + const [issueLinkOpen, setIssueLinkOpen] = useState(false); const [linkedIssues, setLinkedIssues] = useState([]); const inputRootRef = useRef(null); diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx index e140e3ff141..4433c822ced 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx @@ -1,8 +1,8 @@ +import { MessageResponse } from "@superset/ui/ai-elements/message"; import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; import { cn } from "@superset/ui/lib/utils"; import { BotIcon } from "lucide-react"; -import { useId, useMemo, useState } from "react"; -import { MarkdownToggleContent } from "renderer/components/Chat/components/MarkdownToggleContent"; +import { useMemo } from "react"; import type { ToolPart } from "../../../../utils/tool-helpers"; import { parseSubagentToolResult } from "./utils/parseSubagentToolResult"; @@ -23,8 +23,6 @@ export function SubagentToolCall({ args, result, }: SubagentToolCallProps) { - const [renderMarkdown, setRenderMarkdown] = useState(true); - const markdownToggleId = useId(); const isPending = part.state !== "output-available" && part.state !== "output-error"; const isError = @@ -85,14 +83,15 @@ export function SubagentToolCall({
) : null} {parsed.text ? ( - +
+ + {parsed.text} + +
) : null}
) : undefined} diff --git a/apps/desktop/src/renderer/components/Chat/components/MarkdownToggleContent/MarkdownToggleContent.tsx b/apps/desktop/src/renderer/components/Chat/components/MarkdownToggleContent/MarkdownToggleContent.tsx deleted file mode 100644 index 4411123790f..00000000000 --- a/apps/desktop/src/renderer/components/Chat/components/MarkdownToggleContent/MarkdownToggleContent.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { MessageResponse } from "@superset/ui/ai-elements/message"; -import { Switch } from "@superset/ui/switch"; - -interface MarkdownToggleContentProps { - toggleId: string; - checked: boolean; - onCheckedChange: (checked: boolean) => void; - content: string; - labelClassName?: string; - markdownContainerClassName?: string; - plainContainerClassName?: string; -} - -export function MarkdownToggleContent({ - toggleId, - checked, - onCheckedChange, - content, - labelClassName = "flex cursor-pointer items-center gap-2 text-muted-foreground", - markdownContainerClassName = "max-h-64 overflow-auto rounded border bg-background/80 p-2", - plainContainerClassName = "max-h-64 overflow-auto rounded border bg-background/80 p-2 text-xs whitespace-pre-wrap break-words", -}: MarkdownToggleContentProps) { - return ( - <> - - {checked ? ( -
- - {content} - -
- ) : ( -
{content}
- )} - - ); -} diff --git a/apps/desktop/src/renderer/components/Chat/components/MarkdownToggleContent/index.ts b/apps/desktop/src/renderer/components/Chat/components/MarkdownToggleContent/index.ts deleted file mode 100644 index fb38d303ed2..00000000000 --- a/apps/desktop/src/renderer/components/Chat/components/MarkdownToggleContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MarkdownToggleContent } from "./MarkdownToggleContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx index 11573df272a..07a699f7ad4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx @@ -1,7 +1,5 @@ -import { Message, MessageContent } from "@superset/ui/ai-elements/message"; +import { Message, MessageContent, MessageResponse } from "@superset/ui/ai-elements/message"; import { cn } from "@superset/ui/lib/utils"; -import { useState } from "react"; -import { MarkdownToggleContent } from "renderer/components/Chat/components/MarkdownToggleContent"; import { type SubagentEntries, toSubagentViewModels, @@ -32,9 +30,6 @@ export function SubagentExecutionMessage({ subagents, inline = false, }: SubagentExecutionMessageProps) { - const [markdownBySubagent, setMarkdownBySubagent] = useState< - Record - >({}); if (subagents.length === 0) return null; const viewModels = toSubagentViewModels(subagents); @@ -70,20 +65,15 @@ export function SubagentExecutionMessage({ : ""}
{subagent.text ? ( - - setMarkdownBySubagent((previous) => ({ - ...previous, - [subagent.toolCallId]: checked, - })) - } - content={subagent.text} - labelClassName="flex cursor-pointer items-center gap-2 text-xs text-muted-foreground" - markdownContainerClassName="max-h-[32rem] overflow-auto rounded border bg-background/80 p-2" - plainContainerClassName="max-h-[32rem] overflow-auto rounded border bg-background/80 p-2 text-xs whitespace-pre-wrap break-words" - /> +
+ + {subagent.text} + +
) : null} {subagent.toolCalls.length > 0 ? (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx index 174989d1551..9ab557f2051 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx @@ -984,6 +984,8 @@ export function ChatPaneInterface({ clearRuntimeError(); setAnsweredQuestionId(trimmedQuestionId); setQuestionResponsePending(true); + // Clear the orange dot immediately when the user submits their answer + useTabsStore.getState().setPaneStatus(paneId, "idle"); try { await commands.respondToQuestion({ payload: { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx index 11573df272a..07a699f7ad4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx @@ -1,7 +1,5 @@ -import { Message, MessageContent } from "@superset/ui/ai-elements/message"; +import { Message, MessageContent, MessageResponse } from "@superset/ui/ai-elements/message"; import { cn } from "@superset/ui/lib/utils"; -import { useState } from "react"; -import { MarkdownToggleContent } from "renderer/components/Chat/components/MarkdownToggleContent"; import { type SubagentEntries, toSubagentViewModels, @@ -32,9 +30,6 @@ export function SubagentExecutionMessage({ subagents, inline = false, }: SubagentExecutionMessageProps) { - const [markdownBySubagent, setMarkdownBySubagent] = useState< - Record - >({}); if (subagents.length === 0) return null; const viewModels = toSubagentViewModels(subagents); @@ -70,20 +65,15 @@ export function SubagentExecutionMessage({ : ""}
{subagent.text ? ( - - setMarkdownBySubagent((previous) => ({ - ...previous, - [subagent.toolCallId]: checked, - })) - } - content={subagent.text} - labelClassName="flex cursor-pointer items-center gap-2 text-xs text-muted-foreground" - markdownContainerClassName="max-h-[32rem] overflow-auto rounded border bg-background/80 p-2" - plainContainerClassName="max-h-[32rem] overflow-auto rounded border bg-background/80 p-2 text-xs whitespace-pre-wrap break-words" - /> +
+ + {subagent.text} + +
) : null} {subagent.toolCalls.length > 0 ? (
diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index 281fcec69e5..f68c140eb8e 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -1,5 +1,4 @@ import { useNavigate } from "@tanstack/react-router"; -import { useRef } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { NOTIFICATION_EVENTS } from "shared/constants"; @@ -32,17 +31,24 @@ import { resolveNotificationTarget } from "./utils/resolve-notification-target"; * Note: Terminal exit detection (in Terminal.tsx) provides a reliable fallback * for clearing stuck indicators when agent hooks fail to fire. */ -export function useAgentHookListener() { - const navigate = useNavigate(); - // Ref avoids stale closure; parsed from URL since hook runs in _authenticated/layout - const currentWorkspaceIdRef = useRef(null); +/** + * Returns the current workspace ID from the live URL hash. + * The app uses hash routing: file:///.../index.html#/workspace/ + * We must read window.location.hash (not pathname) at event time since the + * _authenticated layout does not re-render on workspace navigation. + */ +function getCurrentWorkspaceId(): string | null { try { - const match = window.location.pathname.match(/\/workspace\/([^/]+)/); - currentWorkspaceIdRef.current = match ? match[1] : null; + const match = window.location.hash.match(/\/workspace\/([^/?#]+)/); + return match ? match[1] : null; } catch { - currentWorkspaceIdRef.current = null; + return null; } +} + +export function useAgentHookListener() { + const navigate = useNavigate(); electronTrpc.notifications.subscribe.useSubscription(undefined, { onData: (event) => { @@ -72,19 +78,35 @@ export function useAgentHookListener() { } else if (eventType === "Stop") { const activeTabId = state.activeTabIds[workspaceId]; const pane = state.panes[paneId]; + const tabId = pane?.tabId; + // Tab must be active for this workspace + const isTabActive = tabId != null && tabId === activeTabId; + // User is on this workspace if the URL hash matches OR if they have this + // pane focused (more reliable than URL parsing which can lag behind navigation) + const isPaneFocused = + tabId != null && state.focusedPaneIds[tabId] === paneId; const isInActiveTab = - currentWorkspaceIdRef.current === workspaceId && - pane?.tabId === activeTabId; + isTabActive && + (getCurrentWorkspaceId() === workspaceId || isPaneFocused); + + // If stopping from a pending question state, always go idle (user already engaged) + const nextStatus = + pane?.status === "permission" + ? "idle" + : isInActiveTab + ? "idle" + : "review"; debugLog("agent-hooks", "Stop event:", { isInActiveTab, activeTabId, paneTabId: pane?.tabId, paneId, - willSetTo: isInActiveTab ? "idle" : "review", + paneStatus: pane?.status, + willSetTo: nextStatus, }); - state.setPaneStatus(paneId, isInActiveTab ? "idle" : "review"); + state.setPaneStatus(paneId, nextStatus); } } else if (event.type === NOTIFICATION_EVENTS.TERMINAL_EXIT) { // Clear transient status for unmounted panes (mounted panes handle this via stream subscription) From 17cd679f769e553b421f501366e8ee546416b452 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Mon, 30 Mar 2026 22:07:11 -0400 Subject: [PATCH 27/70] feat(chat): render subagent task prompt with markdown via MessageResponse Render the subagent task text through MessageResponse so markdown formatting (lists, bold, code spans) is applied consistently with the response text below it. --- .../ReadOnlyToolCall/ReadOnlyToolCall.tsx | 80 ++++++++++++++- .../SubagentToolCall/SubagentToolCall.tsx | 57 +++++------ .../parseSubagentToolResult.ts | 65 +++++++++++-- .../SubagentInnerToolCall.tsx | 97 +++++++++++++++++++ .../components/SubagentInnerToolCall/index.ts | 1 + .../SubagentExecutionMessage.tsx | 47 ++++----- .../utils/toSubagentViewModels.ts | 12 +++ .../SubagentExecutionMessage.tsx | 47 ++++----- .../utils/toSubagentViewModels.ts | 12 +++ .../ui/src/components/ai-elements/message.tsx | 11 ++- 10 files changed, 323 insertions(+), 106 deletions(-) create mode 100644 apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/SubagentInnerToolCall.tsx create mode 100644 apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/index.ts diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx index 1c33fae4637..18a9361cbde 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx @@ -1,5 +1,7 @@ +import { CodeBlock } from "@superset/ui/ai-elements/code-block"; import { ToolInput, ToolOutput } from "@superset/ui/ai-elements/tool"; import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; +import type { BundledLanguage } from "shiki"; import { getToolName } from "ai"; import { ExternalLinkIcon, @@ -40,6 +42,65 @@ function toStringValue(value: unknown): string | undefined { return undefined; } +function getLanguageFromPath(filePath: string): BundledLanguage { + const ext = filePath.split(".").pop()?.toLowerCase(); + const languageMap: Partial> = { + ts: "typescript", + tsx: "tsx", + js: "javascript", + jsx: "jsx", + json: "json", + md: "markdown", + mdx: "mdx", + css: "css", + html: "html", + py: "python", + rb: "ruby", + rs: "rust", + go: "go", + java: "java", + sh: "bash", + bash: "bash", + zsh: "bash", + yaml: "yaml", + yml: "yaml", + toml: "toml", + sql: "sql", + graphql: "graphql", + gql: "graphql", + xml: "xml", + svg: "xml", + prisma: "prisma", + swift: "swift", + kt: "kotlin", + cs: "csharp", + cpp: "cpp", + c: "c", + php: "php", + }; + return (ext && languageMap[ext]) ?? "plaintext"; +} + +function stripReadToolMetadata(content: string): string { + const lines = content.split("\n"); + let startIdx = 0; + + // Skip header line like "filename.ext (8111 bytes)" + if (lines.length > 0 && /^.+\s+\(\d+\s+bytes\)$/.test(lines[0])) { + startIdx = 1; + } + + const contentLines = lines.slice(startIdx); + + // Strip "N→" line number prefixes if present + const lineNumberPattern = /^\d+→/; + if (contentLines.some((line) => lineNumberPattern.test(line))) { + return contentLines.map((line) => line.replace(lineNumberPattern, "")).join("\n"); + } + + return contentLines.join("\n"); +} + function extractReadFileContent(output: unknown): string | undefined { const direct = toStringValue(output); if (direct) return direct; @@ -166,11 +227,20 @@ export function ReadOnlyToolCall({ > {hasDetails ? ( isReadFileTool && !isError && readFileContent ? ( -
-
-							{readFileContent}
-						
-
+ ) : (
{part.input != null && } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx index 4433c822ced..8e39fe25cdf 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx @@ -1,8 +1,8 @@ import { MessageResponse } from "@superset/ui/ai-elements/message"; import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; -import { cn } from "@superset/ui/lib/utils"; import { BotIcon } from "lucide-react"; import { useMemo } from "react"; +import { SubagentInnerToolCall } from "renderer/components/Chat/components/SubagentInnerToolCall"; import type { ToolPart } from "../../../../utils/tool-helpers"; import { parseSubagentToolResult } from "./utils/parseSubagentToolResult"; @@ -36,9 +36,7 @@ export function SubagentToolCall({ const hasDetails = task.length > 0 || parsed.text.length > 0 || - parsed.tools.length > 0 || - Boolean(parsed.modelId) || - parsed.durationMs !== undefined; + parsed.tools.length > 0; // Title: "Agent" (foreground) — agentType goes in description (muted) const titleNode = ( @@ -57,41 +55,36 @@ export function SubagentToolCall({ > {hasDetails ? (
-
{task}
-
- {agentType} - {parsed.modelId ? ` • ${parsed.modelId}` : ""} - {parsed.durationMs !== undefined - ? ` • ${Math.round(parsed.durationMs)} ms` - : ""} -
+ + {task} + {parsed.tools.length > 0 ? ( -
+
{parsed.tools.map((tool, index) => ( - - {tool.name} - + name={tool.name} + isError={tool.isError} + args={tool.args} + result={tool.result} + /> ))}
) : null} {parsed.text ? ( -
- - {parsed.text} - -
+ + {parsed.text} + ) : null}
) : undefined} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/utils/parseSubagentToolResult/parseSubagentToolResult.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/utils/parseSubagentToolResult/parseSubagentToolResult.ts index 8d829331b38..eb6e9d98fa5 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/utils/parseSubagentToolResult/parseSubagentToolResult.ts +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/utils/parseSubagentToolResult/parseSubagentToolResult.ts @@ -1,6 +1,8 @@ -interface SubagentToolExecution { +export interface SubagentToolExecution { name: string; isError: boolean; + args: Record | null; + result: string | null; } export interface SubagentToolResultSummary { @@ -26,7 +28,45 @@ function firstString(...values: unknown[]): string | null { return null; } -function parseTools(value: string | undefined): SubagentToolExecution[] { +function parseDetailedToolCalls( + content: string, +): { tools: SubagentToolExecution[]; stripped: string } | null { + const match = content.match( + /\n([\s\S]*?)<\/subagent-tool-calls>/, + ); + if (!match) return null; + try { + const parsed = JSON.parse(match[1]); + if (!Array.isArray(parsed)) return null; + const tools = parsed + .filter( + (item): item is Record => + typeof item === "object" && item !== null, + ) + .map((item) => ({ + name: typeof item.name === "string" ? item.name : "tool", + isError: item.isError === true, + args: + typeof item.args === "object" && item.args !== null + ? (item.args as Record) + : null, + result: + typeof item.result === "string" + ? item.result + : item.result !== null && item.result !== undefined + ? String(item.result) + : null, + })); + const stripped = + content.slice(0, match.index) + + content.slice((match.index ?? 0) + match[0].length); + return { tools, stripped }; + } catch { + return null; + } +} + +function parseLegacyTools(value: string | undefined): SubagentToolExecution[] { if (!value) return []; return value .split(",") @@ -38,7 +78,9 @@ function parseTools(value: string | undefined): SubagentToolExecution[] { const status = statusPart?.trim().toLowerCase() || "ok"; return { name, - isError: status === "error" || status === "failed", + isError: status === "error" || status === "failed" || status === "err", + args: null, + result: null, }; }); } @@ -49,12 +91,17 @@ export function parseSubagentToolResult( const record = asRecord(value); const textContent = firstString(record?.content, record?.result, record?.text) ?? ""; - const metaTagRegex = /]+?)\s*\/>/i; - const match = textContent.match(metaTagRegex); + + // Try to parse the detailed tool-calls block first + const detailed = parseDetailedToolCalls(textContent); + const workingContent = detailed ? detailed.stripped : textContent; + + const metaTagRegex = /\n?]+?)\s*\/>/i; + const match = workingContent.match(metaTagRegex); if (!match) { return { - text: textContent, - tools: [], + text: workingContent.trim(), + tools: detailed?.tools ?? [], }; } @@ -67,10 +114,10 @@ export function parseSubagentToolResult( const durationRaw = attrs.get("durationMs"); const durationMs = durationRaw ? Number(durationRaw) : Number.NaN; return { - text: textContent.replace(metaTagRegex, "").trim(), + text: workingContent.replace(metaTagRegex, "").trim(), modelId: attrs.get("modelId"), durationMs: Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : undefined, - tools: parseTools(attrs.get("tools")), + tools: detailed?.tools ?? parseLegacyTools(attrs.get("tools")), }; } diff --git a/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/SubagentInnerToolCall.tsx b/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/SubagentInnerToolCall.tsx new file mode 100644 index 00000000000..947ded7a993 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/SubagentInnerToolCall.tsx @@ -0,0 +1,97 @@ +import { BashTool } from "@superset/ui/ai-elements/bash-tool"; +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; +import { + CodeIcon, + FileIcon, + FileTextIcon, + FolderIcon, + GlobeIcon, + SearchIcon, + TerminalIcon, + WrenchIcon, +} from "lucide-react"; +import type { ComponentType } from "react"; +import { getExecuteCommandViewModel } from "renderer/components/Chat/ChatInterface/components/ToolCallBlock/utils/getExecuteCommandViewModel"; +import { normalizeToolName } from "renderer/components/Chat/ChatInterface/utils/tool-helpers"; + +interface SubagentInnerToolCallProps { + name: string; + isError: boolean; + isPending?: boolean; + args: Record | null; + result: string | null; +} + +interface ToolMeta { + label: string; + icon: ComponentType<{ className?: string }>; +} + +const TOOL_META: Record = { + mastra_workspace_execute_command: { + label: "Bash", + icon: TerminalIcon, + }, + mastra_workspace_write_file: { label: "Write", icon: FileIcon }, + mastra_workspace_edit_file: { label: "Edit", icon: FileTextIcon }, + mastra_workspace_read_file: { label: "Read", icon: FileTextIcon }, + mastra_workspace_list_files: { label: "List Files", icon: FolderIcon }, + mastra_workspace_search: { label: "Search", icon: SearchIcon }, + mastra_workspace_mkdir: { label: "Create Directory", icon: FolderIcon }, + mastra_workspace_delete: { label: "Delete", icon: FileIcon }, + ast_smart_edit: { label: "Smart Edit", icon: CodeIcon }, + web_fetch: { label: "Web Fetch", icon: GlobeIcon }, + web_search: { label: "Web Search", icon: GlobeIcon }, +}; + +function getToolMeta(toolName: string): ToolMeta { + return ( + TOOL_META[toolName] ?? { + label: toolName.replaceAll("_", " "), + icon: WrenchIcon, + } + ); +} + +export function SubagentInnerToolCall({ + name, + isError, + isPending = false, + args, + result, +}: SubagentInnerToolCallProps) { + const normalized = normalizeToolName(name); + const state = isPending + ? ("input-available" as const) + : isError + ? ("output-error" as const) + : ("output-available" as const); + + if (normalized === "mastra_workspace_execute_command") { + const argsRecord = args ?? {}; + const resultRecord = result !== null ? { content: result } : {}; + const { command, stdout, stderr, exitCode } = getExecuteCommandViewModel({ + args: argsRecord, + result: resultRecord, + }); + return ( + + ); + } + + const { label, icon } = getToolMeta(normalized); + return ( + + ); +} diff --git a/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/index.ts b/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/index.ts new file mode 100644 index 00000000000..e2ec02722a2 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/index.ts @@ -0,0 +1 @@ +export { SubagentInnerToolCall } from "./SubagentInnerToolCall"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx index 07a699f7ad4..5c91b2455a4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx @@ -1,5 +1,6 @@ import { Message, MessageContent, MessageResponse } from "@superset/ui/ai-elements/message"; import { cn } from "@superset/ui/lib/utils"; +import { SubagentInnerToolCall } from "renderer/components/Chat/components/SubagentInnerToolCall"; import { type SubagentEntries, toSubagentViewModels, @@ -57,41 +58,29 @@ export function SubagentExecutionMessage({ {getStatusLabel(subagent.status)}
-
- {subagent.agentType} - {subagent.modelId ? ` • ${subagent.modelId}` : ""} - {subagent.durationMs !== undefined - ? ` • ${Math.round(subagent.durationMs)} ms` - : ""} -
- {subagent.text ? ( -
- - {subagent.text} - -
- ) : null} {subagent.toolCalls.length > 0 ? ( -
+
{subagent.toolCalls.map((tool, index) => ( - - {tool.name} - + name={tool.name} + isError={tool.isError} + isPending={subagent.status === "running" && index === subagent.toolCalls.length - 1} + args={tool.args} + result={tool.result} + /> ))}
) : null} + {subagent.text ? ( + + {subagent.text} + + ) : null}
))}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts index 892909213b1..3fe28f41af2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts @@ -12,6 +12,8 @@ export type SubagentStatus = "running" | "completed" | "error"; interface SubagentToolCall { name: string; isError: boolean; + args: Record | null; + result: string | null; } export interface SubagentViewModel { @@ -88,6 +90,16 @@ function toToolCalls(value: unknown): SubagentToolCall[] { return { name, isError: record.isError === true, + args: + typeof record.args === "object" && record.args !== null + ? (record.args as Record) + : null, + result: + typeof record.result === "string" + ? record.result + : record.result !== null && record.result !== undefined + ? String(record.result) + : null, }; }) .filter((item): item is SubagentToolCall => item !== null); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx index 07a699f7ad4..5c91b2455a4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx @@ -1,5 +1,6 @@ import { Message, MessageContent, MessageResponse } from "@superset/ui/ai-elements/message"; import { cn } from "@superset/ui/lib/utils"; +import { SubagentInnerToolCall } from "renderer/components/Chat/components/SubagentInnerToolCall"; import { type SubagentEntries, toSubagentViewModels, @@ -57,41 +58,29 @@ export function SubagentExecutionMessage({ {getStatusLabel(subagent.status)}
-
- {subagent.agentType} - {subagent.modelId ? ` • ${subagent.modelId}` : ""} - {subagent.durationMs !== undefined - ? ` • ${Math.round(subagent.durationMs)} ms` - : ""} -
- {subagent.text ? ( -
- - {subagent.text} - -
- ) : null} {subagent.toolCalls.length > 0 ? ( -
+
{subagent.toolCalls.map((tool, index) => ( - - {tool.name} - + name={tool.name} + isError={tool.isError} + isPending={subagent.status === "running" && index === subagent.toolCalls.length - 1} + args={tool.args} + result={tool.result} + /> ))}
) : null} + {subagent.text ? ( + + {subagent.text} + + ) : null}
))}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts index 01782b488b4..901ec6cdc74 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts @@ -12,6 +12,8 @@ export type SubagentStatus = "running" | "completed" | "error"; interface SubagentToolCall { name: string; isError: boolean; + args: Record | null; + result: string | null; } export interface SubagentViewModel { @@ -88,6 +90,16 @@ function toToolCalls(value: unknown): SubagentToolCall[] { return { name, isError: record.isError === true, + args: + typeof record.args === "object" && record.args !== null + ? (record.args as Record) + : null, + result: + typeof record.result === "string" + ? record.result + : record.result !== null && record.result !== undefined + ? String(record.result) + : null, }; }) .filter((item): item is SubagentToolCall => item !== null); diff --git a/packages/ui/src/components/ai-elements/message.tsx b/packages/ui/src/components/ai-elements/message.tsx index 0e45ba6f9ea..c6ec4e742df 100644 --- a/packages/ui/src/components/ai-elements/message.tsx +++ b/packages/ui/src/components/ai-elements/message.tsx @@ -9,7 +9,13 @@ import { XIcon, } from "lucide-react"; import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; -import { createContext, memo, useContext, useEffect, useState } from "react"; +import { + createContext, + memo, + useContext, + useEffect, + useState, +} from "react"; import type { PluginConfig } from "streamdown"; import { Streamdown } from "streamdown"; import { cn } from "../../lib/utils"; @@ -320,9 +326,10 @@ export const MessageResponse = memo( *:first-child]:mt-0 [&>*:last-child]:mb-0 [&_ol]:list-outside [&_ol]:pl-6 [&_ul]:list-outside [&_ul]:pl-6 [&_li]:break-words [&_li]:whitespace-pre-wrap [&_p]:break-words [&_p]:whitespace-pre-wrap [&_table]:min-w-full [&_table]:w-max [&_:not(pre)>code]:break-all", + "text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_ol]:list-outside [&_ol]:pl-6 [&_ul]:list-outside [&_ul]:pl-6 [&_li]:break-words [&_li]:whitespace-pre-wrap [&_p]:break-words [&_p]:whitespace-pre-wrap [&_:not(pre)>code]:break-all [&_[data-streamdown=table-wrapper]]:overflow-hidden [&_[data-streamdown=table-wrapper]]:bg-transparent [&_[data-streamdown=table-wrapper]]:p-0 [&_[data-streamdown=table-wrapper]]:gap-0 [&_[data-streamdown=table-wrapper]>div]:border-0 [&_[data-streamdown=table-wrapper]>div]:bg-transparent [&_[data-streamdown=table-wrapper]>div]:rounded-none [&_thead]:bg-muted/50", className, )} + controls={{ table: false }} isAnimating={isAnimating} linkSafety={{ enabled: false }} mode="streaming" From 7bd9a2736c28a609c4fb0cc1f97b530f737dcb1b Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Mon, 30 Mar 2026 22:48:37 -0400 Subject: [PATCH 28/70] fix(chat): scale down headings and fix list layout in subagent output Headings were rendering at full browser size (text-2xl/3xl) inside the compact xs subagent block. Override h1-h6 to text-sm/xs with tighter margins, and remove the top margin on first-child paragraphs inside list items to fix ordered list numbers appearing on a separate line. --- .../SubagentToolCall/SubagentToolCall.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx index 8e39fe25cdf..33f0173459a 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx @@ -6,6 +6,18 @@ import { SubagentInnerToolCall } from "renderer/components/Chat/components/Subag import type { ToolPart } from "../../../../utils/tool-helpers"; import { parseSubagentToolResult } from "./utils/parseSubagentToolResult"; +// Scale headings and fix list layout for the compact xs context. +// [&_h*] selectors (specificity 0-1-1) beat Streamdown's direct element +// classes (0-1-0) so no !important needed. +const mdClassName = + "[&_h1]:text-sm [&_h1]:font-bold [&_h1]:mt-2 [&_h1]:mb-0.5 " + + "[&_h2]:text-sm [&_h2]:font-bold [&_h2]:mt-2 [&_h2]:mb-0.5 " + + "[&_h3]:text-xs [&_h3]:font-semibold [&_h3]:mt-1.5 [&_h3]:mb-0 " + + "[&_h4]:text-xs [&_h4]:font-semibold " + + "[&_h5]:text-xs [&_h5]:font-medium " + + "[&_h6]:text-xs [&_h6]:font-medium " + + "[&_li>p:first-child]:mt-0"; + interface SubagentToolCallProps { part: ToolPart; args: Record; @@ -57,7 +69,7 @@ export function SubagentToolCall({
@@ -79,7 +91,7 @@ export function SubagentToolCall({ {parsed.text ? ( From 6142b82fbfe641330b49ed75dfd2c0b7fa4594e7 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Mon, 30 Mar 2026 22:52:16 -0400 Subject: [PATCH 29/70] feat(chat): render read file tool output with syntax-highlighted code viewer - ReadOnlyToolCall fetches file content directly via tRPC filesystem.readFile (same path as the file pane) instead of parsing MCP tool output, eliminating metadata artifacts like line-number prefixes and byte-count headers - Uses shared detectLanguage() for syntax highlighting language detection - Renders with CodeBlock (Shiki) with a filename + line-range header styled like table headers (bg-muted/50), showLineNumbers, and colorize=false for plain white text - Adds colorize prop to CodeBlock to suppress syntax colors while keeping line numbers at reduced opacity - Passes workspaceId/workspaceCwd from ToolCallBlock to ReadOnlyToolCall - Fixes withoutActiveTurnAssistantHistory to preserve completed prior-phase assistant messages (e.g. read-file before a question answer) by keeping messages that have a stopReason and a different id from currentMessage, preventing tool calls from disappearing after answering a question --- .../ReadOnlyToolCall/ReadOnlyToolCall.tsx | 184 +++++++----------- .../ToolCallBlock/ToolCallBlock.tsx | 9 +- .../use-chat-display/use-chat-display.ts | 20 +- .../src/components/ai-elements/code-block.tsx | 13 +- 4 files changed, 105 insertions(+), 121 deletions(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx index 18a9361cbde..617c719834f 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx @@ -1,7 +1,6 @@ import { CodeBlock } from "@superset/ui/ai-elements/code-block"; import { ToolInput, ToolOutput } from "@superset/ui/ai-elements/tool"; import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; -import type { BundledLanguage } from "shiki"; import { getToolName } from "ai"; import { ExternalLinkIcon, @@ -10,7 +9,13 @@ import { FolderTreeIcon, SearchIcon, } from "lucide-react"; -import { getWorkspaceToolFilePath } from "../../utils/file-paths"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { detectLanguage } from "shared/detect-language"; +import type { BundledLanguage } from "shiki"; +import { + getWorkspaceToolFilePath, + normalizeWorkspaceFilePath, +} from "../../utils/file-paths"; import type { ToolPart } from "../../utils/tool-helpers"; import { getArgs, @@ -27,107 +32,17 @@ function stringify(value: unknown): string { } } -function toRecord(value: unknown): Record | undefined { - if (typeof value === "object" && value !== null && !Array.isArray(value)) { - return value as Record; - } - return undefined; -} - -function toStringValue(value: unknown): string | undefined { - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - return undefined; -} - -function getLanguageFromPath(filePath: string): BundledLanguage { - const ext = filePath.split(".").pop()?.toLowerCase(); - const languageMap: Partial> = { - ts: "typescript", - tsx: "tsx", - js: "javascript", - jsx: "jsx", - json: "json", - md: "markdown", - mdx: "mdx", - css: "css", - html: "html", - py: "python", - rb: "ruby", - rs: "rust", - go: "go", - java: "java", - sh: "bash", - bash: "bash", - zsh: "bash", - yaml: "yaml", - yml: "yaml", - toml: "toml", - sql: "sql", - graphql: "graphql", - gql: "graphql", - xml: "xml", - svg: "xml", - prisma: "prisma", - swift: "swift", - kt: "kotlin", - cs: "csharp", - cpp: "cpp", - c: "c", - php: "php", - }; - return (ext && languageMap[ext]) ?? "plaintext"; -} - -function stripReadToolMetadata(content: string): string { - const lines = content.split("\n"); - let startIdx = 0; - - // Skip header line like "filename.ext (8111 bytes)" - if (lines.length > 0 && /^.+\s+\(\d+\s+bytes\)$/.test(lines[0])) { - startIdx = 1; - } - - const contentLines = lines.slice(startIdx); - - // Strip "N→" line number prefixes if present - const lineNumberPattern = /^\d+→/; - if (contentLines.some((line) => lineNumberPattern.test(line))) { - return contentLines.map((line) => line.replace(lineNumberPattern, "")).join("\n"); - } - - return contentLines.join("\n"); -} - -function extractReadFileContent(output: unknown): string | undefined { - const direct = toStringValue(output); - if (direct) return direct; - - const record = toRecord(output); - if (!record) return undefined; - - const nestedResult = toRecord(record.result); - - return ( - toStringValue(record.content) ?? - toStringValue(record.text) ?? - toStringValue(record.stdout) ?? - toStringValue(record.data) ?? - toStringValue(nestedResult?.content) ?? - toStringValue(nestedResult?.text) ?? - toStringValue(nestedResult?.stdout) - ); -} - interface ReadOnlyToolCallProps { part: ToolPart; + workspaceId?: string; + workspaceCwd?: string; onOpenFileInPane?: (filePath: string) => void; } export function ReadOnlyToolCall({ part, + workspaceId, + workspaceCwd, onOpenFileInPane, }: ReadOnlyToolCallProps) { const args = getArgs(part); @@ -143,11 +58,45 @@ export function ReadOnlyToolCall({ part.state !== "output-available" && part.state !== "output-error"; const displayState = toToolDisplayState(part); const isReadFileTool = toolName === "mastra_workspace_read_file"; - const readFileContent = isReadFileTool - ? extractReadFileContent(output) - : undefined; const hasDetails = part.input != null || output != null || isError; + const rawFilePath = isReadFileTool + ? String(args.path ?? args.filePath ?? args.file_path ?? args.file ?? "") + : ""; + const absoluteFilePath = rawFilePath + ? normalizeWorkspaceFilePath({ filePath: rawFilePath, workspaceRoot: workspaceCwd }) + : null; + + const fileQuery = electronTrpc.filesystem.readFile.useQuery( + { + workspaceId: workspaceId ?? "", + absolutePath: absoluteFilePath ?? "", + encoding: "utf-8", + }, + { + enabled: + isReadFileTool && !isPending && !!absoluteFilePath && !!workspaceId, + retry: false, + refetchOnWindowFocus: false, + }, + ); + + const fileContent = fileQuery.data?.content as string | undefined; + + const lineRange = fileContent + ? (() => { + const startLine = + Number( + args.startLine ?? args.start_line ?? args.offset ?? args.from ?? 1, + ) || 1; + const lineCount = fileContent.split("\n").length; + const endLine = startLine + lineCount - 1; + return startLine === 1 && endLine === lineCount + ? `1–${lineCount}` + : `${startLine}–${endLine}`; + })() + : null; + let title = "Read file"; let subtitle = String(args.path ?? args.filePath ?? args.query ?? ""); let Icon = FileIcon; @@ -226,21 +175,28 @@ export function ReadOnlyToolCall({ title={title} > {hasDetails ? ( - isReadFileTool && !isError && readFileContent ? ( - + isReadFileTool && !isError && fileContent ? ( +
+
+
+ + {(absoluteFilePath ?? rawFilePath).split("/").pop()} + + {lineRange && ( + {lineRange} + )} +
+ +
+
) : (
{part.input != null && } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx index 980032311f4..1d1ab64c2ab 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx @@ -584,7 +584,14 @@ export function ToolCallBlock({ // --- Read-only exploration tools --- if (READ_ONLY_TOOLS.has(toolName)) { - return ; + return ( + + ); } // --- Destructive workspace tools --- diff --git a/packages/chat/src/client/hooks/use-chat-display/use-chat-display.ts b/packages/chat/src/client/hooks/use-chat-display/use-chat-display.ts index 84d112bdfc7..a70d709abe9 100644 --- a/packages/chat/src/client/hooks/use-chat-display/use-chat-display.ts +++ b/packages/chat/src/client/hooks/use-chat-display/use-chat-display.ts @@ -76,11 +76,23 @@ export function withoutActiveTurnAssistantHistory({ const turnStartIndex = findLastUserMessageIndex(messages) + 1; const previousTurns = messages.slice(0, turnStartIndex); - const activeTurnNonAssistant = messages - .slice(turnStartIndex) - .filter((message: HistoryMessage) => message.role !== "assistant"); + const activeTurnMessages = messages.slice(turnStartIndex); - return [...previousTurns, ...activeTurnNonAssistant]; + // Keep a historical assistant message only when it is both: + // 1. Fully completed (has a stopReason) — a completed prior phase such as + // the read-file + ask_user message before a question answer. + // 2. Not the message currently being streamed (different id from currentMessage) + // — guards the brief transition window where the same message is committed + // to history while currentMessage still references it. + const currentMessageId = (currentMessage as { id?: string }).id; + const deduped = activeTurnMessages.filter((message: HistoryMessage) => { + if (message.role !== "assistant") return true; + const stopReason = (message as unknown as { stopReason?: string }).stopReason; + const messageId = (message as unknown as { id?: string }).id; + return !!stopReason && messageId !== currentMessageId; + }); + + return [...previousTurns, ...deduped]; } function hasFileOrImagePart(message: HistoryMessage): boolean { diff --git a/packages/ui/src/components/ai-elements/code-block.tsx b/packages/ui/src/components/ai-elements/code-block.tsx index 31d9127398d..7037824e4c9 100644 --- a/packages/ui/src/components/ai-elements/code-block.tsx +++ b/packages/ui/src/components/ai-elements/code-block.tsx @@ -17,6 +17,8 @@ type CodeBlockProps = HTMLAttributes & { code: string; language: BundledLanguage; showLineNumbers?: boolean; + /** When false, suppresses syntax-highlight colors — all tokens render in the foreground color. */ + colorize?: boolean; }; type CodeBlockContextType = { @@ -75,6 +77,7 @@ export const CodeBlock = ({ code, language, showLineNumbers = false, + colorize = true, className, children, ...props @@ -106,12 +109,18 @@ export const CodeBlock = ({ >
pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm", + !colorize && "[&_span[style]]:!text-foreground [&_.line>span:first-child]:!opacity-50", + )} // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed." dangerouslySetInnerHTML={{ __html: html }} />
pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm", + !colorize && "[&_span[style]]:!text-foreground [&_.line>span:first-child]:!opacity-50", + )} // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed." dangerouslySetInnerHTML={{ __html: darkHtml }} /> From 55de0fb6a3939dcd205094024ec363687c2faa27 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Tue, 31 Mar 2026 07:30:51 -0400 Subject: [PATCH 30/70] feat(chat): improve tool call error and task_write UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ToolCallRow: replace XIcon with "ERROR" label + XCircleIcon in description slot on error - SupersetToolCall: add subtitle prop, render output content via MessageResponse instead of raw JSON - TaskWriteToolCall: new component for task_write — "Update Tasks" title with ListTodoIcon and semantic description (task count + status breakdown) --- .../ToolCallBlock/ToolCallBlock.tsx | 3 +- .../SupersetToolCall/SupersetToolCall.tsx | 20 ++++++- .../TaskWriteToolCall/TaskWriteToolCall.tsx | 57 +++++++++++++++++++ .../components/TaskWriteToolCall/index.ts | 1 + .../components/ai-elements/tool-call-row.tsx | 17 ++++-- 5 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/TaskWriteToolCall/TaskWriteToolCall.tsx create mode 100644 apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/TaskWriteToolCall/index.ts diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx index 1d1ab64c2ab..c84924aa51d 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx @@ -37,6 +37,7 @@ import { ListTasksToolCall } from "./components/ListTasksToolCall"; import { ListWorkspacesToolCall } from "./components/ListWorkspacesToolCall"; import { StartAgentSessionToolCall } from "./components/StartAgentSessionToolCall"; import { SubagentToolCall } from "./components/SubagentToolCall"; +import { TaskWriteToolCall } from "./components/TaskWriteToolCall"; import { SupersetToolCall } from "./components/SupersetToolCall"; import { SwitchWorkspaceToolCall } from "./components/SwitchWorkspaceToolCall"; import { UpdateTaskToolCall } from "./components/UpdateTaskToolCall"; @@ -616,7 +617,7 @@ export function ToolCallBlock({ } if (toolName === "task_write") { - return ; + return ; } if (toolName === "task_check") { diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx index 1f35b134530..b3816ae9847 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx @@ -1,3 +1,4 @@ +import { MessageResponse } from "@superset/ui/ai-elements/message"; import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; import { WrenchIcon } from "lucide-react"; import type { ComponentType, ReactNode } from "react"; @@ -9,6 +10,7 @@ type SupersetToolCallProps = { toolName: string; icon?: ComponentType<{ className?: string }>; details?: ReactNode; + subtitle?: string; }; function stringifyValue(value: unknown): string { @@ -25,6 +27,7 @@ export function SupersetToolCall({ toolName, icon: Icon = WrenchIcon, details, + subtitle, }: SupersetToolCallProps) { const output = "output" in part ? (part as { output?: unknown }).output : undefined; @@ -46,7 +49,17 @@ export function SupersetToolCall({ return "Tool failed"; }, [isError, output, outputError, outputObject?.message]); - const hasDetails = Boolean(details) || isError; + const contentText = (() => { + if (isPending || isError) return null; + if (typeof output === "string" && output.trim()) return output.trim(); + if (outputObject) { + const c = outputObject.content ?? outputObject.text; + if (typeof c === "string" && c.trim()) return c.trim(); + } + return null; + })(); + + const hasDetails = Boolean(details) || isError || contentText != null; return ( {hasDetails ? (
@@ -66,6 +80,10 @@ export function SupersetToolCall({
{errorText}
+ ) : contentText != null ? ( + + {contentText} + ) : null}
) : undefined} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/TaskWriteToolCall/TaskWriteToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/TaskWriteToolCall/TaskWriteToolCall.tsx new file mode 100644 index 00000000000..075da85e262 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/TaskWriteToolCall/TaskWriteToolCall.tsx @@ -0,0 +1,57 @@ +import { ListTodoIcon } from "lucide-react"; +import type { ToolPart } from "../../../../utils/tool-helpers"; +import { getArgs } from "../../../../utils/tool-helpers"; +import { SupersetToolCall } from "../SupersetToolCall"; + +interface TodoItem { + id: string; + content: string; + status: "pending" | "in_progress" | "completed"; + priority?: string; +} + +function toTodoItems(value: unknown): TodoItem[] { + if (!Array.isArray(value)) return []; + return value.filter( + (item): item is TodoItem => + typeof item === "object" && + item !== null && + typeof (item as TodoItem).content === "string", + ); +} + +function buildDescription(todos: TodoItem[]): string | undefined { + if (todos.length === 0) return undefined; + + const inProgress = todos.filter((t) => t.status === "in_progress").length; + const completed = todos.filter((t) => t.status === "completed").length; + const pending = todos.filter((t) => t.status === "pending").length; + + const parts: string[] = [`${todos.length} task${todos.length === 1 ? "" : "s"}`]; + const statusParts: string[] = []; + if (inProgress > 0) statusParts.push(`${inProgress} in progress`); + if (completed > 0) statusParts.push(`${completed} completed`); + if (pending > 0) statusParts.push(`${pending} pending`); + if (statusParts.length > 0) parts.push(statusParts.join(" · ")); + + return parts.join(" · "); +} + +interface TaskWriteToolCallProps { + part: ToolPart; +} + +export function TaskWriteToolCall({ part }: TaskWriteToolCallProps) { + const args = getArgs(part); + const todos = toTodoItems(args.todos); + const description = buildDescription(todos); + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/TaskWriteToolCall/index.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/TaskWriteToolCall/index.ts new file mode 100644 index 00000000000..fcf501bd5a4 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/TaskWriteToolCall/index.ts @@ -0,0 +1 @@ +export { TaskWriteToolCall } from "./TaskWriteToolCall"; diff --git a/packages/ui/src/components/ai-elements/tool-call-row.tsx b/packages/ui/src/components/ai-elements/tool-call-row.tsx index ee3a7a62f6e..ef7e4d5d6fc 100644 --- a/packages/ui/src/components/ai-elements/tool-call-row.tsx +++ b/packages/ui/src/components/ai-elements/tool-call-row.tsx @@ -3,7 +3,7 @@ import { ChevronDownIcon, ChevronRightIcon, - XIcon, + XCircleIcon, } from "lucide-react"; import type { ComponentType, ReactNode } from "react"; import { useState } from "react"; @@ -70,6 +70,15 @@ export function ToolCallRow({ const defaultStatus = null; + const resolvedDescription = + description ?? + (isError ? ( + + + Error + + ) : null); + const titleContent = typeof title === "string" ? ( {title} @@ -109,15 +118,13 @@ export function ToolCallRow({ - ) : isError ? ( - ) : ( )} {titleContent} - {description != null && !isOpen && ( + {resolvedDescription != null && !isOpen && ( - {description} + {resolvedDescription} )}
From 41015ef9d44e0513b3540f99cb075f864fc24906 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Tue, 31 Mar 2026 07:33:40 -0400 Subject: [PATCH 31/70] fix(chat): add vertical padding to read file tool content area --- .../components/ReadOnlyToolCall/ReadOnlyToolCall.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx index 617c719834f..a7046cfee93 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx @@ -176,7 +176,7 @@ export function ReadOnlyToolCall({ > {hasDetails ? ( isReadFileTool && !isError && fileContent ? ( -
+
From ec717d66e9f19ce3a7c987c876e16ad41eef87d9 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Tue, 31 Mar 2026 07:38:38 -0400 Subject: [PATCH 32/70] feat(chat): add LspInspectToolCall with ActivityIcon and file subtitle --- .../ToolCallBlock/ToolCallBlock.tsx | 5 ++++ .../LspInspectToolCall/LspInspectToolCall.tsx | 27 +++++++++++++++++++ .../components/LspInspectToolCall/index.ts | 1 + .../Chat/ChatInterface/utils/tool-helpers.ts | 1 + 4 files changed, 34 insertions(+) create mode 100644 apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx create mode 100644 apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/index.ts diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx index c84924aa51d..a65acf29d7d 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx @@ -37,6 +37,7 @@ import { ListTasksToolCall } from "./components/ListTasksToolCall"; import { ListWorkspacesToolCall } from "./components/ListWorkspacesToolCall"; import { StartAgentSessionToolCall } from "./components/StartAgentSessionToolCall"; import { SubagentToolCall } from "./components/SubagentToolCall"; +import { LspInspectToolCall } from "./components/LspInspectToolCall"; import { TaskWriteToolCall } from "./components/TaskWriteToolCall"; import { SupersetToolCall } from "./components/SupersetToolCall"; import { SwitchWorkspaceToolCall } from "./components/SwitchWorkspaceToolCall"; @@ -616,6 +617,10 @@ export function ToolCallBlock({ return ; } + if (toolName === "lsp_inspect") { + return ; + } + if (toolName === "task_write") { return ; } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx new file mode 100644 index 00000000000..2c21bc0672e --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx @@ -0,0 +1,27 @@ +import { ActivityIcon } from "lucide-react"; +import type { ToolPart } from "../../../../utils/tool-helpers"; +import { getArgs } from "../../../../utils/tool-helpers"; +import { SupersetToolCall } from "../SupersetToolCall"; + +interface LspInspectToolCallProps { + part: ToolPart; +} + +export function LspInspectToolCall({ part }: LspInspectToolCallProps) { + const args = getArgs(part); + const rawPath = String( + args.file_path ?? args.filePath ?? args.path ?? args.file ?? "", + ); + const fileName = rawPath.includes("/") + ? rawPath.split("/").pop() + : rawPath || undefined; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/index.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/index.ts new file mode 100644 index 00000000000..a8c95496e1c --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/index.ts @@ -0,0 +1 @@ +export { LspInspectToolCall } from "./LspInspectToolCall"; diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts index b08aade3641..56edc9f6e79 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts @@ -33,6 +33,7 @@ const TOOL_NAME_ALIASES: Record = { task_write: "task_write", task_check: "task_check", submit_plan: "submit_plan", + lsp_inspect: "lsp_inspect", // Legacy Superset MCP names create_worktree: "create_workspace", From 3eb1d8541625c54c24a6ad79ea0834722b196e16 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Tue, 31 Mar 2026 07:41:47 -0400 Subject: [PATCH 33/70] fix(chat): handle mastra_workspace_lsp_inspect tool name alias --- .../renderer/components/Chat/ChatInterface/utils/tool-helpers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts index 56edc9f6e79..2248a598674 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts @@ -34,6 +34,7 @@ const TOOL_NAME_ALIASES: Record = { task_check: "task_check", submit_plan: "submit_plan", lsp_inspect: "lsp_inspect", + mastra_workspace_lsp_inspect: "lsp_inspect", // Legacy Superset MCP names create_worktree: "create_workspace", From abd2f9ea19d19728603dcde6dd5592deb3a991ad Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Tue, 31 Mar 2026 08:06:14 -0400 Subject: [PATCH 34/70] fix(chat): use FileSearchIcon for LSP Inspect tool call --- .../components/LspInspectToolCall/LspInspectToolCall.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx index 2c21bc0672e..f7d59637505 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx @@ -1,4 +1,4 @@ -import { ActivityIcon } from "lucide-react"; +import { FileSearchIcon } from "lucide-react"; import type { ToolPart } from "../../../../utils/tool-helpers"; import { getArgs } from "../../../../utils/tool-helpers"; import { SupersetToolCall } from "../SupersetToolCall"; @@ -20,7 +20,7 @@ export function LspInspectToolCall({ part }: LspInspectToolCallProps) { ); From d35a014ceeef26efa324ca572a84066f54f26236 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Tue, 31 Mar 2026 08:07:26 -0400 Subject: [PATCH 35/70] feat(chat): show input/output content in LSP Inspect tool call --- .../LspInspectToolCall/LspInspectToolCall.tsx | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx index f7d59637505..5f99516bb31 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx @@ -1,7 +1,9 @@ +import { ToolInput, ToolOutput } from "@superset/ui/ai-elements/tool"; +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; import { FileSearchIcon } from "lucide-react"; import type { ToolPart } from "../../../../utils/tool-helpers"; import { getArgs } from "../../../../utils/tool-helpers"; -import { SupersetToolCall } from "../SupersetToolCall"; +import { getGenericToolCallState } from "../GenericToolCall/getGenericToolCallState"; interface LspInspectToolCallProps { part: ToolPart; @@ -9,6 +11,10 @@ interface LspInspectToolCallProps { export function LspInspectToolCall({ part }: LspInspectToolCallProps) { const args = getArgs(part); + const { output, isError, errorText } = getGenericToolCallState(part); + const isPending = + part.state !== "output-available" && part.state !== "output-error"; + const rawPath = String( args.file_path ?? args.filePath ?? args.path ?? args.file ?? "", ); @@ -16,12 +22,27 @@ export function LspInspectToolCall({ part }: LspInspectToolCallProps) { ? rawPath.split("/").pop() : rawPath || undefined; + const hasDetails = part.input != null || output != null || isError; + return ( - + isError={isError} + isPending={isPending} + title="LSP Inspect" + description={fileName} + > + {hasDetails ? ( +
+ {part.input != null && } + {(output != null || isError) && ( + + )} +
+ ) : undefined} + ); } From f475669c09f5fee799baf667f302e93a827b5f6d Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Tue, 31 Mar 2026 08:08:10 -0400 Subject: [PATCH 36/70] fix(chat): use SearchCheckIcon for LSP Inspect tool call --- .../components/LspInspectToolCall/LspInspectToolCall.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx index 5f99516bb31..af4193ec640 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx @@ -1,6 +1,6 @@ import { ToolInput, ToolOutput } from "@superset/ui/ai-elements/tool"; import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; -import { FileSearchIcon } from "lucide-react"; +import { SearchCheckIcon } from "lucide-react"; import type { ToolPart } from "../../../../utils/tool-helpers"; import { getArgs } from "../../../../utils/tool-helpers"; import { getGenericToolCallState } from "../GenericToolCall/getGenericToolCallState"; @@ -26,7 +26,7 @@ export function LspInspectToolCall({ part }: LspInspectToolCallProps) { return ( Date: Tue, 31 Mar 2026 09:47:28 -0400 Subject: [PATCH 37/70] fix(chat): extract TOOL_CALL_MD_CLASSNAME for global compact markdown in tool calls Adds inline code (text-xs) fix alongside existing heading overrides. SubagentToolCall and SupersetToolCall now share the same constant so future patches only need to happen in one place. --- .../SubagentToolCall/SubagentToolCall.tsx | 26 +++++++++---------- .../SupersetToolCall/SupersetToolCall.tsx | 4 +-- .../ui/src/components/ai-elements/message.tsx | 18 +++++++++++++ 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx index 33f0173459a..15423474fdb 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx @@ -1,4 +1,4 @@ -import { MessageResponse } from "@superset/ui/ai-elements/message"; +import { MessageResponse, TOOL_CALL_MD_CLASSNAME } from "@superset/ui/ai-elements/message"; import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; import { BotIcon } from "lucide-react"; import { useMemo } from "react"; @@ -6,22 +6,14 @@ import { SubagentInnerToolCall } from "renderer/components/Chat/components/Subag import type { ToolPart } from "../../../../utils/tool-helpers"; import { parseSubagentToolResult } from "./utils/parseSubagentToolResult"; -// Scale headings and fix list layout for the compact xs context. -// [&_h*] selectors (specificity 0-1-1) beat Streamdown's direct element -// classes (0-1-0) so no !important needed. -const mdClassName = - "[&_h1]:text-sm [&_h1]:font-bold [&_h1]:mt-2 [&_h1]:mb-0.5 " + - "[&_h2]:text-sm [&_h2]:font-bold [&_h2]:mt-2 [&_h2]:mb-0.5 " + - "[&_h3]:text-xs [&_h3]:font-semibold [&_h3]:mt-1.5 [&_h3]:mb-0 " + - "[&_h4]:text-xs [&_h4]:font-semibold " + - "[&_h5]:text-xs [&_h5]:font-medium " + - "[&_h6]:text-xs [&_h6]:font-medium " + - "[&_li>p:first-child]:mt-0"; interface SubagentToolCallProps { part: ToolPart; args: Record; result: Record; + workspaceId?: string; + workspaceCwd?: string; + onOpenFileInPane?: (filePath: string) => void; } function asString(value: unknown): string | null { @@ -34,6 +26,9 @@ export function SubagentToolCall({ part, args, result, + workspaceId, + workspaceCwd, + onOpenFileInPane, }: SubagentToolCallProps) { const isPending = part.state !== "output-available" && part.state !== "output-error"; @@ -69,7 +64,7 @@ export function SubagentToolCall({
@@ -84,6 +79,9 @@ export function SubagentToolCall({ isError={tool.isError} args={tool.args} result={tool.result} + workspaceId={workspaceId} + workspaceCwd={workspaceCwd} + onOpenFileInPane={onOpenFileInPane} /> ))}
@@ -91,7 +89,7 @@ export function SubagentToolCall({ {parsed.text ? ( diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx index b3816ae9847..d9968fc439a 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx @@ -1,4 +1,4 @@ -import { MessageResponse } from "@superset/ui/ai-elements/message"; +import { MessageResponse, TOOL_CALL_MD_CLASSNAME } from "@superset/ui/ai-elements/message"; import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; import { WrenchIcon } from "lucide-react"; import type { ComponentType, ReactNode } from "react"; @@ -81,7 +81,7 @@ export function SupersetToolCall({ {errorText}
) : contentText != null ? ( - + {contentText} ) : null} diff --git a/packages/ui/src/components/ai-elements/message.tsx b/packages/ui/src/components/ai-elements/message.tsx index c6ec4e742df..01d5e4ecc79 100644 --- a/packages/ui/src/components/ai-elements/message.tsx +++ b/packages/ui/src/components/ai-elements/message.tsx @@ -319,6 +319,24 @@ export const MessageBranchPage = ({ ); }; +/** + * Compact Tailwind overrides for MessageResponse used inside tool call content. + * Apply this className to any MessageResponse rendered inside a collapsible + * tool call body so all markdown elements scale down to xs context. + */ +export const TOOL_CALL_MD_CLASSNAME = + // Headings + "[&_h1]:text-sm [&_h1]:font-bold [&_h1]:mt-2 [&_h1]:mb-0.5 " + + "[&_h2]:text-sm [&_h2]:font-bold [&_h2]:mt-2 [&_h2]:mb-0.5 " + + "[&_h3]:text-xs [&_h3]:font-semibold [&_h3]:mt-1.5 [&_h3]:mb-0 " + + "[&_h4]:text-xs [&_h4]:font-semibold " + + "[&_h5]:text-xs [&_h5]:font-medium " + + "[&_h6]:text-xs [&_h6]:font-medium " + + // Inline code + "[&_:not(pre)>code]:text-xs " + + // Lists + "[&_li>p:first-child]:mt-0"; + export type MessageResponseProps = ComponentProps; export const MessageResponse = memo( From f2fd66a6c3290608f6ec79335156c718f454db09 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Tue, 31 Mar 2026 10:00:16 -0400 Subject: [PATCH 38/70] feat(chat): improve subagent tool call display and share read-file component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Filter empty messages (step-start/source-only) from chat history - Add expandable content with subtitles to subagent inner tool calls (Read, List Files, Search, Write, Edit, Web) - Extract shared ReadFileTool component to packages/ui for reuse across main and subagent tool calls - Subagent read tool now shows styled CodeBlock with syntax highlighting, matching main agent display - Thread workspaceId/workspaceCwd/onOpenFileInPane through SubagentToolCall → SubagentInnerToolCall so subagent read calls show the open-in-pane button --- .../components/MessageList/MessageList.tsx | 16 +- .../ReadOnlyToolCall/ReadOnlyToolCall.tsx | 64 +++---- .../ToolCallBlock/ToolCallBlock.tsx | 11 +- .../SubagentInnerToolCall.tsx | 169 +++++++++++++++++- .../components/ai-elements/read-file-tool.tsx | 78 ++++++++ 5 files changed, 298 insertions(+), 40 deletions(-) create mode 100644 packages/ui/src/components/ai-elements/read-file-tool.tsx diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx index 4ccc872d72f..a5cc031c795 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx @@ -8,6 +8,16 @@ import { Message, MessageContent } from "@superset/ui/ai-elements/message"; import { ShimmerLabel } from "@superset/ui/ai-elements/shimmer-label"; import { getToolName, isToolUIPart } from "ai"; import type { ChatStatus, UIMessage } from "ai"; + +function hasRenderableParts(parts: UIMessage["parts"]): boolean { + return parts.some( + (p) => + p.type === "text" || + p.type === "reasoning" || + (p as { type: string }).type === "error" || + isToolUIPart(p), + ); +} import { FileIcon, FileTextIcon, ImageIcon } from "lucide-react"; import { useCallback } from "react"; import { HiMiniChatBubbleLeftRight } from "react-icons/hi2"; @@ -234,10 +244,14 @@ export function MessageList({ ); } + const showThinking = + isLastAssistant && isThinking && msg.parts.length === 0; + if (!showThinking && !hasRenderableParts(msg.parts)) return null; + return ( - {isLastAssistant && isThinking && msg.parts.length === 0 ? ( + {showThinking ? ( Thinking... diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx index a7046cfee93..3c04b28799d 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx @@ -1,4 +1,4 @@ -import { CodeBlock } from "@superset/ui/ai-elements/code-block"; +import { ReadFileTool } from "@superset/ui/ai-elements/read-file-tool"; import { ToolInput, ToolOutput } from "@superset/ui/ai-elements/tool"; import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; import { getToolName } from "ai"; @@ -165,6 +165,22 @@ export function ReadOnlyToolCall({ ) : undefined; + if (isReadFileTool && !isError && fileContent) { + const displayPath = absoluteFilePath ?? rawFilePath; + const filename = displayPath.split("/").pop() ?? displayPath; + return ( + onOpenFileInPane?.(filePath) : undefined} + /> + ); + } + return ( {hasDetails ? ( - isReadFileTool && !isError && fileContent ? ( -
-
-
- - {(absoluteFilePath ?? rawFilePath).split("/").pop()} - - {lineRange && ( - {lineRange} - )} -
- -
-
- ) : ( -
- {part.input != null && } - {(output != null || isError) && ( - - )} -
- ) +
+ {part.input != null && } + {(output != null || isError) && ( + + )} +
) : undefined}
); diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx index a65acf29d7d..90f89555d60 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx @@ -634,7 +634,16 @@ export function ToolCallBlock({ } if (toolName === "subagent") { - return ; + return ( + + ); } // --- Fallback: generic tool UI --- diff --git a/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/SubagentInnerToolCall.tsx b/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/SubagentInnerToolCall.tsx index 947ded7a993..70dd8023bc8 100644 --- a/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/SubagentInnerToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/SubagentInnerToolCall.tsx @@ -1,8 +1,10 @@ import { BashTool } from "@superset/ui/ai-elements/bash-tool"; +import { ReadFileTool } from "@superset/ui/ai-elements/read-file-tool"; import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; import { CodeIcon, FileIcon, + FileSearchIcon, FileTextIcon, FolderIcon, GlobeIcon, @@ -13,6 +15,7 @@ import { import type { ComponentType } from "react"; import { getExecuteCommandViewModel } from "renderer/components/Chat/ChatInterface/components/ToolCallBlock/utils/getExecuteCommandViewModel"; import { normalizeToolName } from "renderer/components/Chat/ChatInterface/utils/tool-helpers"; +import { detectLanguage } from "shared/detect-language"; interface SubagentInnerToolCallProps { name: string; @@ -20,6 +23,9 @@ interface SubagentInnerToolCallProps { isPending?: boolean; args: Record | null; result: string | null; + workspaceId?: string; + workspaceCwd?: string; + onOpenFileInPane?: (filePath: string) => void; } interface ToolMeta { @@ -34,8 +40,9 @@ const TOOL_META: Record = { }, mastra_workspace_write_file: { label: "Write", icon: FileIcon }, mastra_workspace_edit_file: { label: "Edit", icon: FileTextIcon }, - mastra_workspace_read_file: { label: "Read", icon: FileTextIcon }, + mastra_workspace_read_file: { label: "Read", icon: FileIcon }, mastra_workspace_list_files: { label: "List Files", icon: FolderIcon }, + mastra_workspace_file_stat: { label: "Check file", icon: FileSearchIcon }, mastra_workspace_search: { label: "Search", icon: SearchIcon }, mastra_workspace_mkdir: { label: "Create Directory", icon: FolderIcon }, mastra_workspace_delete: { label: "Delete", icon: FileIcon }, @@ -53,12 +60,127 @@ function getToolMeta(toolName: string): ToolMeta { ); } +function getDescription( + toolName: string, + args: Record | null, +): string | undefined { + if (!args) return undefined; + + let raw: string | undefined; + + switch (toolName) { + case "mastra_workspace_read_file": + case "mastra_workspace_write_file": + case "mastra_workspace_edit_file": + case "mastra_workspace_file_stat": + case "mastra_workspace_delete": + case "ast_smart_edit": + raw = + String( + args.path ?? args.filePath ?? args.file_path ?? args.file ?? "", + ) || undefined; + break; + case "mastra_workspace_list_files": + case "mastra_workspace_mkdir": + raw = + String( + args.path ?? + args.directory ?? + args.directoryPath ?? + args.directory_path ?? + args.root ?? + args.cwd ?? + "", + ) || undefined; + break; + case "mastra_workspace_search": + raw = + String( + args.query ?? + args.pattern ?? + args.regex ?? + args.substring_pattern ?? + args.text ?? + "", + ) || undefined; + break; + case "web_fetch": + raw = String(args.url ?? args.uri ?? "") || undefined; + break; + case "web_search": + raw = String(args.query ?? args.q ?? "") || undefined; + break; + default: + return undefined; + } + + if (!raw) return undefined; + + // For paths, show only the filename + if ( + raw.includes("/") && + toolName !== "mastra_workspace_search" && + toolName !== "web_fetch" && + toolName !== "web_search" + ) { + return raw.split("/").pop() ?? raw; + } + + return raw; +} + +/** + * The Mastra workspace read_file tool returns content in this format: + * /path/to/file (N bytes) + * 1\tline one + * 2\tline two + * + * Strip the header and line-number prefixes to get clean file content. + */ +function parseReadFileResult(result: string): { + filename: string; + content: string; + lineCount: number; +} | null { + const lines = result.split("\n"); + if (lines.length < 2) return null; + + // First line: "path/to/file (N bytes)" or just content + const headerMatch = lines[0].match(/^(.+?)\s*\(\d+\s*bytes?\)\s*$/i); + if (!headerMatch) return null; + + const filename = headerMatch[1].trim(); + const contentLines = lines.slice(1); + + // Strip line-number prefix: " N\t" or " N→" + const stripped = contentLines.map((line) => { + const tabMatch = line.match(/^\s*\d+\t(.*)$/); + if (tabMatch) return tabMatch[1]; + const arrowMatch = line.match(/^\s*\d+\u2192(.*)$/); + if (arrowMatch) return arrowMatch[1]; + return line; + }); + + // Trim trailing blank lines + while (stripped.length > 0 && stripped[stripped.length - 1].trim() === "") { + stripped.pop(); + } + + return { + filename, + content: stripped.join("\n"), + lineCount: stripped.length, + }; +} + export function SubagentInnerToolCall({ name, isError, isPending = false, args, result, + workspaceCwd, + onOpenFileInPane, }: SubagentInnerToolCallProps) { const normalized = normalizeToolName(name); const state = isPending @@ -86,12 +208,55 @@ export function SubagentInnerToolCall({ } const { label, icon } = getToolMeta(normalized); + const description = getDescription(normalized, args); + const hasResult = result !== null && result.trim().length > 0; + + // Read file: parse and display using the shared ReadFileTool component + if (normalized === "mastra_workspace_read_file" && hasResult && result) { + const parsed = parseReadFileResult(result); + if (parsed) { + const filename = parsed.filename.split("/").pop() ?? parsed.filename; + const lineRange = `1–${parsed.lineCount}`; + const openInPane = onOpenFileInPane + ? () => { + const filePath = String( + args?.path ?? args?.filePath ?? args?.file_path ?? args?.file ?? parsed.filename, + ); + const normalized = workspaceCwd && !filePath.startsWith("/") + ? `${workspaceCwd}/${filePath}` + : filePath; + onOpenFileInPane(normalized); + } + : undefined; + return ( + + ); + } + } + return ( + description={description} + > + {hasResult ? ( +
+
+ {result} +
+
+ ) : undefined} +
); } diff --git a/packages/ui/src/components/ai-elements/read-file-tool.tsx b/packages/ui/src/components/ai-elements/read-file-tool.tsx new file mode 100644 index 00000000000..ed7a8eb2839 --- /dev/null +++ b/packages/ui/src/components/ai-elements/read-file-tool.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { ExternalLinkIcon, FileIcon } from "lucide-react"; +import type { BundledLanguage } from "shiki"; +import { CodeBlock } from "./code-block"; +import { ToolCallRow } from "./tool-call-row"; + +export type ReadFileToolProps = { + /** Basename shown in the header (e.g. "ReadOnlyToolCall.tsx"). */ + filename: string; + /** Parsed, clean file content (no line-number prefixes). */ + content: string; + /** Line range label shown in the header (e.g. "1–217"). */ + lineRange?: string; + /** Shiki language for syntax highlighting. Defaults to "text". */ + language?: BundledLanguage; + isError?: boolean; + isPending?: boolean; + /** When provided, renders an "open in pane" icon button in the header. */ + onOpenInPane?: () => void; + className?: string; +}; + +/** + * Shared read-file tool call display used by both the main agent's + * ReadOnlyToolCall and the subagent's SubagentInnerToolCall. + */ +export function ReadFileTool({ + filename, + content, + lineRange, + language = "text" as BundledLanguage, + isError = false, + isPending = false, + onOpenInPane, + className, +}: ReadFileToolProps) { + const headerExtra = onOpenInPane ? ( + + ) : undefined; + + return ( + +
+
+
+ {filename} + {lineRange && ( + {lineRange} + )} +
+ +
+
+
+ ); +} From 14b3da8378057a37077e987fb592e2cf3cfe089d Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Tue, 31 Mar 2026 10:39:58 -0400 Subject: [PATCH 39/70] fix(chat): forward workspace props to ReadOnlyToolCall in MessagePartsRenderer workspaceId and workspaceCwd were available in MessagePartsRenderer but not forwarded to ReadOnlyToolCall, silently disabling the disk-read feature for read_file tool calls rendered via that path. --- .../components/MessagePartsRenderer/MessagePartsRenderer.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessagePartsRenderer/MessagePartsRenderer.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessagePartsRenderer/MessagePartsRenderer.tsx index 8bed1e7e06f..d3388210636 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessagePartsRenderer/MessagePartsRenderer.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessagePartsRenderer/MessagePartsRenderer.tsx @@ -164,6 +164,8 @@ export function MessagePartsRenderer({ , ); @@ -192,6 +194,8 @@ export function MessagePartsRenderer({ , ); From a4b460c84da13d047b8e2ec058199b33ea6ddeec Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Tue, 31 Mar 2026 10:40:04 -0400 Subject: [PATCH 40/70] fix(chat): remove dead code from MessageList - Remove interruptedByAbortedQuestion which was computed but never referenced in JSX or logic - Move hasRenderableParts to after imports (was inserted between them) - Remove unused getToolName, normalizeToolName, ToolPart imports - Add comment explaining the runtime-only "error" part type cast --- .../components/MessageList/MessageList.tsx | 45 +++++-------------- 1 file changed, 11 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx index a5cc031c795..6e604c3b317 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx @@ -6,18 +6,8 @@ import { } from "@superset/ui/ai-elements/conversation"; import { Message, MessageContent } from "@superset/ui/ai-elements/message"; import { ShimmerLabel } from "@superset/ui/ai-elements/shimmer-label"; -import { getToolName, isToolUIPart } from "ai"; +import { isToolUIPart } from "ai"; import type { ChatStatus, UIMessage } from "ai"; - -function hasRenderableParts(parts: UIMessage["parts"]): boolean { - return parts.some( - (p) => - p.type === "text" || - p.type === "reasoning" || - (p as { type: string }).type === "error" || - isToolUIPart(p), - ); -} import { FileIcon, FileTextIcon, ImageIcon } from "lucide-react"; import { useCallback } from "react"; import { HiMiniChatBubbleLeftRight } from "react-icons/hi2"; @@ -27,11 +17,19 @@ import { parseUserMentions } from "renderer/components/Chat/utils/parseUserMenti import { useTabsStore } from "renderer/stores/tabs/store"; import type { InterruptedMessagePreview } from "../../types"; import { normalizeWorkspaceFilePath } from "../../utils/file-paths"; -import { normalizeToolName } from "../../utils/tool-helpers"; -import type { ToolPart } from "../../utils/tool-helpers"; import { MessagePartsRenderer } from "../MessagePartsRenderer"; import { MessageScrollbackRail } from "./components/MessageScrollbackRail"; +function hasRenderableParts(parts: UIMessage["parts"]): boolean { + return parts.some( + (p) => + p.type === "text" || + p.type === "reasoning" || + (p as { type: string }).type === "error" || // "error" part type exists at runtime but is not yet in the UIMessage union + isToolUIPart(p), + ); +} + interface MessageListProps { messages: UIMessage[]; interruptedMessage?: InterruptedMessagePreview | null; @@ -89,27 +87,6 @@ export function MessageList({ [workspaceId, addFileViewerPane], ); - const interruptedByAbortedQuestion = - interruptedMessage?.parts.some((part) => { - if (!isToolUIPart(part)) return false; - const toolPart = part as ToolPart; - const toolName = normalizeToolName(getToolName(toolPart)); - if (toolName !== "ask_user_question") return false; - // Pending (awaiting response when run was stopped) - if ( - toolPart.state !== "output-available" && - toolPart.state !== "output-error" - ) - return true; - if (toolPart.state === "output-error") return true; - const out = toolPart.output; - return ( - typeof out === "object" && - out !== null && - (out as Record).isError === true - ); - }) ?? false; - return ( From 11f4cfacc5adf6a5ea955d128083ed91169f5a75 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Tue, 31 Mar 2026 10:40:10 -0400 Subject: [PATCH 41/70] fix(chat): fix type safety, path normalization, and memoize in SubagentInnerToolCall - Replace as never with as BundledLanguage for language prop type safety - Replace naive path concatenation with normalizeWorkspaceFilePath to handle ./, ../, file:// and workspace boundary validation (matches ReadOnlyToolCall) - Rename shadowed normalized variable to resolvedPath in openInPane closure - Memoize parseReadFileResult call to avoid re-parsing on every render --- .../SubagentInnerToolCall.tsx | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/SubagentInnerToolCall.tsx b/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/SubagentInnerToolCall.tsx index 70dd8023bc8..e4060ad3a0e 100644 --- a/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/SubagentInnerToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/SubagentInnerToolCall.tsx @@ -12,10 +12,12 @@ import { TerminalIcon, WrenchIcon, } from "lucide-react"; -import type { ComponentType } from "react"; +import { useMemo, type ComponentType } from "react"; import { getExecuteCommandViewModel } from "renderer/components/Chat/ChatInterface/components/ToolCallBlock/utils/getExecuteCommandViewModel"; +import { normalizeWorkspaceFilePath } from "renderer/components/Chat/ChatInterface/utils/file-paths"; import { normalizeToolName } from "renderer/components/Chat/ChatInterface/utils/tool-helpers"; import { detectLanguage } from "shared/detect-language"; +import type { BundledLanguage } from "shiki"; interface SubagentInnerToolCallProps { name: string; @@ -212,20 +214,28 @@ export function SubagentInnerToolCall({ const hasResult = result !== null && result.trim().length > 0; // Read file: parse and display using the shared ReadFileTool component - if (normalized === "mastra_workspace_read_file" && hasResult && result) { - const parsed = parseReadFileResult(result); + const parsedReadFile = useMemo( + () => + normalized === "mastra_workspace_read_file" && hasResult + ? parseReadFileResult(result!) + : null, + [normalized, hasResult, result], + ); + if (normalized === "mastra_workspace_read_file" && hasResult && parsedReadFile) { + const parsed = parsedReadFile; if (parsed) { const filename = parsed.filename.split("/").pop() ?? parsed.filename; const lineRange = `1–${parsed.lineCount}`; const openInPane = onOpenFileInPane ? () => { - const filePath = String( + const rawPath = String( args?.path ?? args?.filePath ?? args?.file_path ?? args?.file ?? parsed.filename, ); - const normalized = workspaceCwd && !filePath.startsWith("/") - ? `${workspaceCwd}/${filePath}` - : filePath; - onOpenFileInPane(normalized); + const resolvedPath = normalizeWorkspaceFilePath({ + filePath: rawPath, + workspaceRoot: workspaceCwd, + }) ?? rawPath; + onOpenFileInPane(resolvedPath); } : undefined; return ( @@ -233,7 +243,7 @@ export function SubagentInnerToolCall({ filename={filename} content={parsed.content} lineRange={lineRange} - language={detectLanguage(parsed.filename) as never} + language={detectLanguage(parsed.filename) as BundledLanguage} isError={isError} isPending={isPending} onOpenInPane={openInPane} From 0c139329f1b78b3e171b57c7e6622d9feea89305 Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Tue, 31 Mar 2026 10:40:16 -0400 Subject: [PATCH 42/70] fix(chat): add stale time and loading state to ReadOnlyToolCall file query - Add staleTime: Infinity so completed read-file tool calls don't refetch on remount (prevents IPC burst when scrolling long conversations) - Show a spinner row while the disk read is in flight instead of flashing the raw ToolInput/ToolOutput view --- .../components/ReadOnlyToolCall/ReadOnlyToolCall.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx index 3c04b28799d..46770a3d01e 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx @@ -78,6 +78,7 @@ export function ReadOnlyToolCall({ isReadFileTool && !isPending && !!absoluteFilePath && !!workspaceId, retry: false, refetchOnWindowFocus: false, + staleTime: Infinity, }, ); @@ -165,6 +166,11 @@ export function ReadOnlyToolCall({ ) : undefined; + // Prevent a flash of raw output while the disk read is in flight + if (isReadFileTool && !isError && !isPending && !fileContent && fileQuery.isLoading) { + return ; + } + if (isReadFileTool && !isError && fileContent) { const displayPath = absoluteFilePath ?? rawFilePath; const filename = displayPath.split("/").pop() ?? displayPath; From baea6f204d4cc05436c8270c006bed43bd8e6e0a Mon Sep 17 00:00:00 2001 From: Michal Kopanski Date: Wed, 1 Apr 2026 20:51:28 -0400 Subject: [PATCH 43/70] feat(chat): replace text input with Tiptap editor for slash commands and file mentions - Add TiptapPromptEditor with ProseMirror-based rich text input - Slash command chips (/command) as inline atom nodes, insertable anywhere in message - File mention chips (@path) anchored to cursor position via virtual float - SlashCommandMenu width matches prompt input via --radix-popover-trigger-width - Selecting a command inserts a chip node instead of immediately submitting - Popover closes on editor blur, reopens on focus - Tab no longer auto-selects commands (only Enter selects) - serializeEditorToText serializes chip nodes to /name and @path for submission --- .../ChatInputFooter/ChatInputFooter.tsx | 141 ++-- .../SlashCommandMenu/SlashCommandMenu.tsx | 4 +- .../TiptapPromptEditor/FileMentionNode.tsx | 61 ++ .../TiptapPromptEditor/SlashCommandNode.tsx | 141 ++++ .../SlashCommandPreviewPopover.tsx | 124 +++ .../TiptapPromptEditor/TiptapPromptEditor.tsx | 715 ++++++++++++++++++ .../components/TiptapPromptEditor/index.ts | 4 + .../parseTextToEditorContent.ts | 46 ++ .../serializeEditorToText.ts | 32 + .../hooks/useSlashCommands/index.ts | 3 + .../useSlashCommands/useSlashCommands.ts | 2 +- .../ChatInputFooter/ChatInputFooter.tsx | 147 ++-- 12 files changed, 1259 insertions(+), 161 deletions(-) create mode 100644 apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/FileMentionNode.tsx create mode 100644 apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/SlashCommandNode.tsx create mode 100644 apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/SlashCommandPreviewPopover.tsx create mode 100644 apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/TiptapPromptEditor.tsx create mode 100644 apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/index.ts create mode 100644 apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/parseTextToEditorContent.ts create mode 100644 apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/serializeEditorToText.ts diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx index 828d3a782b9..d8bf9ac00b5 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx @@ -3,7 +3,6 @@ import { PromptInputAttachment, PromptInputAttachments, type PromptInputMessage, - PromptInputTextarea, usePromptInputController, } from "@superset/ui/ai-elements/prompt-input"; import type { ThinkingLevel } from "@superset/ui/ai-elements/thinking-toggle"; @@ -16,15 +15,13 @@ import { useHotkeyDisplay } from "renderer/hotkeys"; import type { SlashCommand } from "../../hooks/useSlashCommands"; import type { ModelOption, PermissionMode } from "../../types"; import { IssueLinkCommand } from "../IssueLinkCommand"; -import { MentionAnchor, MentionProvider } from "../MentionPopover"; -import { SlashCommandInput } from "../SlashCommandInput"; +import { TiptapPromptEditor } from "../TiptapPromptEditor"; import { ChatComposerControls } from "./components/ChatComposerControls"; import { ChatInputDropZone } from "./components/ChatInputDropZone"; import { ChatShortcuts } from "./components/ChatShortcuts"; import { FileDropOverlay } from "./components/FileDropOverlay"; import { LinkedIssues } from "./components/LinkedIssues"; import { QuestionInputOverlay } from "./components/QuestionInputOverlay"; -import { SlashCommandPreview } from "./components/SlashCommandPreview"; import type { LinkedIssue } from "./types"; import { getErrorMessage } from "./utils/getErrorMessage"; @@ -92,7 +89,7 @@ export function ChatInputFooter({ useFocusPromptOnPane(isFocused); // Focus the prompt when the question overlay dismisses (pendingQuestion → null). - // Uses rAF so the textarea has time to mount, register its ref, and browser + // Uses rAF so the editor has time to mount, register its ref, and browser // focus-stealing from the unmounting overlay has settled. const { textInput } = usePromptInputController(); const prevPendingQuestionRef = useRef(pendingQuestion); @@ -163,82 +160,66 @@ export function ChatInputFooter({ onCancel={onQuestionCancel} /> ) : ( - - - -
- {showFocusHint && ( - - {focusShortcutText} to focus - - )} - - - - - - {renderAttachment ?? - ((file) => )} - - - - - setIssueLinkOpen(true)} - /> - -
-
-
-
+ + + + + + {renderAttachment ?? + ((file) => )} + + + + setIssueLinkOpen(true)} + /> + +
)}
diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/SlashCommandMenu/SlashCommandMenu.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/SlashCommandMenu/SlashCommandMenu.tsx index 152348fc160..2d0b97bfae3 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/SlashCommandMenu/SlashCommandMenu.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/SlashCommandMenu/SlashCommandMenu.tsx @@ -28,8 +28,8 @@ export function SlashCommandMenu({ e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} > diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/FileMentionNode.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/FileMentionNode.tsx new file mode 100644 index 00000000000..183b27f642d --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/FileMentionNode.tsx @@ -0,0 +1,61 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { + type NodeViewProps, + NodeViewWrapper, + ReactNodeViewRenderer, +} from "@tiptap/react"; +import { cn } from "@superset/ui/utils"; + +function FileMentionChip({ node, selected }: NodeViewProps) { + const path = node.attrs.path as string; + const name = path.split("/").pop() ?? path; + + return ( + + + @ + {name} + + + ); +} + +export const FileMentionNode = Node.create({ + name: "file-mention", + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + path: { + default: null, + parseHTML: (el) => el.getAttribute("data-path"), + renderHTML: (attrs) => ({ "data-path": attrs.path }), + }, + }; + }, + + parseHTML() { + return [{ tag: 'span[data-type="file-mention"]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "span", + mergeAttributes({ "data-type": "file-mention" }, HTMLAttributes), + ]; + }, + + addNodeView() { + return ReactNodeViewRenderer(FileMentionChip); + }, +}); diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/SlashCommandNode.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/SlashCommandNode.tsx new file mode 100644 index 00000000000..36350794304 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/SlashCommandNode.tsx @@ -0,0 +1,141 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { + type NodeViewProps, + NodeViewWrapper, + ReactNodeViewRenderer, +} from "@tiptap/react"; +import { cn } from "@superset/ui/utils"; +import { useCallback, useEffect, useRef } from "react"; + +function SlashCommandChip({ + node, + selected, + updateAttributes, + editor, + getPos, +}: NodeViewProps) { + const name = node.attrs.name as string; + const args = (node.attrs.args as string) ?? ""; + const inputRef = useRef(null); + + // Auto-focus the inline input when this chip is freshly inserted. + // Only run on mount; by then the editor view still holds focus (the suggestion + // plugin just committed the chip), so we can safely transfer focus into the input. + useEffect(() => { + inputRef.current?.focus(); + // Position cursor at end of any pre-filled args + const len = inputRef.current?.value.length ?? 0; + inputRef.current?.setSelectionRange(len, len); + }, []); // mount-only + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + updateAttributes({ args: e.target.value }); + }, + [updateAttributes], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Tab or ArrowRight at the end of input → exit chip into the editor + if ( + e.key === "Tab" || + (e.key === "ArrowRight" && + inputRef.current?.selectionStart === args.length) + ) { + e.preventDefault(); + e.stopPropagation(); + const pos = getPos(); + if (pos !== undefined) { + editor.chain().focus().setTextSelection(pos + node.nodeSize).run(); + } else { + editor.commands.focus("end"); + } + } + // Backspace on empty input → delete the chip and return focus to editor + if (e.key === "Backspace" && args === "") { + e.preventDefault(); + e.stopPropagation(); + const pos = getPos(); + if (pos !== undefined) { + editor.chain() + .focus() + .deleteRange({ from: pos, to: pos + node.nodeSize }) + .run(); + } + } + }, + [args, editor, getPos, node.nodeSize], + ); + + const placeholder = name; + // Size the input to its content, with a sensible minimum + const displayWidth = Math.max(args.length, placeholder.length, 4); + + return ( + + + / + {name} + : + e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + /> + + + ); +} + +export const SlashCommandNode = Node.create({ + name: "slash-command", + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + name: { + default: null, + parseHTML: (el) => el.getAttribute("data-name"), + renderHTML: (attrs) => ({ "data-name": attrs.name }), + }, + args: { + default: "", + parseHTML: (el) => el.getAttribute("data-args") ?? "", + renderHTML: (attrs) => (attrs.args ? { "data-args": attrs.args } : {}), + }, + }; + }, + + parseHTML() { + return [{ tag: 'span[data-type="slash-command"]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "span", + mergeAttributes({ "data-type": "slash-command" }, HTMLAttributes), + ]; + }, + + addNodeView() { + return ReactNodeViewRenderer(SlashCommandChip); + }, +}); diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/SlashCommandPreviewPopover.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/SlashCommandPreviewPopover.tsx new file mode 100644 index 00000000000..3ddb8871617 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/SlashCommandPreviewPopover.tsx @@ -0,0 +1,124 @@ +import { chatServiceTrpc } from "@superset/chat/client"; +import { usePromptInputController } from "@superset/ui/ai-elements/prompt-input"; +import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import type { Editor } from "@tiptap/core"; +import { useLayoutEffect, useMemo, useRef } from "react"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { + normalizeSlashPreviewInput, + parseSlashInput, + resolveSlashCommandDefinition, +} from "../ChatInputFooter/components/SlashCommandPreview/slash-command-preview.model"; + +interface SlashCommandPreviewPopoverProps { + cwd: string; + slashCommands: Array<{ + name: string; + aliases: string[]; + description: string; + argumentHint: string; + }>; + editor: Editor; + isFocused: boolean; +} + +export function SlashCommandPreviewPopover({ + cwd, + slashCommands, + editor, + isFocused, +}: SlashCommandPreviewPopoverProps) { + const { textInput } = usePromptInputController(); + const inputValue = textInput.value; + + const anchorRef = useRef(null); + + // Position the virtual anchor over the slash-command chip in the editor. + useLayoutEffect(() => { + const el = anchorRef.current; + if (!el) return; + let foundPos: number | null = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === "slash-command") { + foundPos = pos; + return false; + } + }); + if (foundPos === null) return; + const dom = editor.view.nodeDOM(foundPos); + if (!(dom instanceof HTMLElement)) return; + const rect = dom.getBoundingClientRect(); + el.style.left = `${rect.left}px`; + el.style.top = `${rect.top}px`; + el.style.width = `${rect.width}px`; + el.style.height = `${rect.height}px`; + }, [editor, inputValue]); + + const slashPreviewInput = normalizeSlashPreviewInput(inputValue); + const parsedInput = useMemo(() => parseSlashInput(inputValue), [inputValue]); + const debouncedSlashPreviewInput = useDebouncedValue(slashPreviewInput, 120); + + const { data: slashPreview } = chatServiceTrpc.workspace.previewSlashCommand.useQuery( + { cwd, text: debouncedSlashPreviewInput }, + { + enabled: debouncedSlashPreviewInput.length > 1 && !!cwd, + staleTime: 250, + placeholderData: (previous) => previous, + }, + ); + + const commandDefinition = useMemo(() => { + if (!parsedInput?.commandName) return null; + return resolveSlashCommandDefinition(slashCommands, parsedInput.commandName); + }, [parsedInput?.commandName, slashCommands]); + + const commandDescription = commandDefinition?.description?.trim() ?? ""; + const previewCommandName = slashPreview?.commandName?.toLowerCase(); + const canonicalCommandName = commandDefinition?.name.toLowerCase(); + const previewMatchesInputCommand = Boolean( + previewCommandName && + canonicalCommandName && + previewCommandName === canonicalCommandName, + ); + const previewPrompt = previewMatchesInputCommand ? (slashPreview?.prompt ?? "") : ""; + + // Show popover when there's an active command with a preview to display + const showPopover = Boolean( + parsedInput && commandDefinition && debouncedSlashPreviewInput && previewPrompt, + ); + + return ( + + +