fix(chat): execution graph status error (#903)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawx",
|
"name": "clawx",
|
||||||
"version": "0.3.10",
|
"version": "0.3.11-beta.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@discordjs/opus",
|
"@discordjs/opus",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Chat Message Component
|
* Chat Message Component
|
||||||
* Renders user / assistant / system / toolresult messages
|
* Renders user / assistant / system / toolresult messages
|
||||||
* with markdown, thinking sections, images, and tool cards.
|
* with markdown, images, and tool cards. Thinking output is
|
||||||
|
* surfaced via ExecutionGraphCard, not inside message bubbles.
|
||||||
*/
|
*/
|
||||||
import { useState, useCallback, useEffect, memo } from 'react';
|
import { useState, useCallback, useEffect, memo } from 'react';
|
||||||
import { Sparkles, Copy, Check, ChevronDown, ChevronRight, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
import { Sparkles, Copy, Check, ChevronDown, ChevronRight, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
@@ -14,7 +15,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { invokeIpc } from '@/lib/api-client';
|
import { invokeIpc } from '@/lib/api-client';
|
||||||
import type { RawMessage, AttachedFileMeta } from '@/stores/chat';
|
import type { RawMessage, AttachedFileMeta } from '@/stores/chat';
|
||||||
import { extractText, extractThinking, extractImages, extractToolUse, formatTimestamp } from './message-utils';
|
import { extractText, extractImages, extractToolUse, formatTimestamp } from './message-utils';
|
||||||
|
|
||||||
interface ChatMessageProps {
|
interface ChatMessageProps {
|
||||||
message: RawMessage;
|
message: RawMessage;
|
||||||
@@ -98,13 +99,11 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
// original content without surfacing the bubble.
|
// original content without surfacing the bubble.
|
||||||
const hideAssistantText = suppressAssistantText && !isUser;
|
const hideAssistantText = suppressAssistantText && !isUser;
|
||||||
const hasText = !hideAssistantText && text.trim().length > 0;
|
const hasText = !hideAssistantText && text.trim().length > 0;
|
||||||
const visibleThinkingRaw = extractThinking(message);
|
|
||||||
const visibleThinking = hideAssistantText ? null : visibleThinkingRaw;
|
|
||||||
const images = extractImages(message);
|
const images = extractImages(message);
|
||||||
const tools = extractToolUse(message);
|
const tools = extractToolUse(message);
|
||||||
const visibleTools = suppressToolCards ? [] : tools;
|
const visibleTools = suppressToolCards ? [] : tools;
|
||||||
const shouldHideProcessAttachments = suppressProcessAttachments
|
const shouldHideProcessAttachments = suppressProcessAttachments
|
||||||
&& (hasText || !!visibleThinking || images.length > 0 || visibleTools.length > 0);
|
&& (hasText || images.length > 0 || visibleTools.length > 0);
|
||||||
|
|
||||||
const attachedFiles = shouldHideProcessAttachments
|
const attachedFiles = shouldHideProcessAttachments
|
||||||
? (message._attachedFiles || []).filter((file) => file.source !== 'tool-result')
|
? (message._attachedFiles || []).filter((file) => file.source !== 'tool-result')
|
||||||
@@ -115,7 +114,7 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
if (isToolResult) return null;
|
if (isToolResult) return null;
|
||||||
|
|
||||||
const hasStreamingToolStatus = isStreaming && streamingTools.length > 0;
|
const hasStreamingToolStatus = isStreaming && streamingTools.length > 0;
|
||||||
if (!hasText && !visibleThinking && images.length === 0 && visibleTools.length === 0 && attachedFiles.length === 0 && !hasStreamingToolStatus) return null;
|
if (!hasText && images.length === 0 && visibleTools.length === 0 && attachedFiles.length === 0 && !hasStreamingToolStatus) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -142,11 +141,6 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
<ToolStatusBar tools={streamingTools} />
|
<ToolStatusBar tools={streamingTools} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Thinking section */}
|
|
||||||
{visibleThinking && (
|
|
||||||
<ThinkingBlock content={visibleThinking} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tool use cards */}
|
{/* Tool use cards */}
|
||||||
{visibleTools.length > 0 && (
|
{visibleTools.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -442,36 +436,6 @@ function MessageBubble({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Thinking Block ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ThinkingBlock({ content }: { content: string }) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full rounded-xl border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 text-[14px]">
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
>
|
|
||||||
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
|
||||||
<span className="font-medium">Thinking</span>
|
|
||||||
</button>
|
|
||||||
{expanded && (
|
|
||||||
<div className="px-3 pb-3 text-muted-foreground">
|
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none opacity-75">
|
|
||||||
<ReactMarkdown
|
|
||||||
remarkPlugins={[remarkGfm, remarkMath]}
|
|
||||||
rehypePlugins={[[rehypeKatex, { strict: false, throwOnError: false, output: 'html' }]]}
|
|
||||||
>
|
|
||||||
{normalizeLatexDelimiters(content)}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── File Card (for user-uploaded non-image files) ───────────────
|
// ── File Card (for user-uploaded non-image files) ───────────────
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ type UserRunCard = {
|
|||||||
steps: TaskStep[];
|
steps: TaskStep[];
|
||||||
messageStepTexts: string[];
|
messageStepTexts: string[];
|
||||||
streamingReplyText: string | null;
|
streamingReplyText: string | null;
|
||||||
|
/**
|
||||||
|
* Whether the trailing "Thinking..." indicator should be hidden for this
|
||||||
|
* card. True only when the run's live stream is currently rendered AS a
|
||||||
|
* streaming step inside the graph (the step itself already signals
|
||||||
|
* liveness, so the extra indicator would be redundant). False in all
|
||||||
|
* other cases — including when the stream is promoted to a bubble
|
||||||
|
* below the graph, or when there is no streaming content at all (the
|
||||||
|
* gap between tool rounds), because the graph has no visible activity
|
||||||
|
* of its own in those windows and the indicator is what tells the user
|
||||||
|
* "work is still in progress".
|
||||||
|
*/
|
||||||
|
suppressThinking: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getPrimaryMessageStepTexts(steps: TaskStep[]): string[] {
|
function getPrimaryMessageStepTexts(steps: TaskStep[]): string[] {
|
||||||
@@ -174,6 +186,13 @@ export function Chat() {
|
|||||||
: 0;
|
: 0;
|
||||||
const streamText = streamMsg ? extractText(streamMsg) : (typeof streamingMessage === 'string' ? streamingMessage : '');
|
const streamText = streamMsg ? extractText(streamMsg) : (typeof streamingMessage === 'string' ? streamingMessage : '');
|
||||||
const hasStreamText = streamText.trim().length > 0;
|
const hasStreamText = streamText.trim().length > 0;
|
||||||
|
// Whether the streaming chunk currently carries a `thinking` block. Used as
|
||||||
|
// a liveness signal so the run stays "active" (and the ExecutionGraphCard
|
||||||
|
// keeps showing its trailing "Thinking..." indicator) during the brief window
|
||||||
|
// between a tool finishing and the next text/tool chunk arriving — that gap
|
||||||
|
// is normally only filled by streamed thinking. NOT included in
|
||||||
|
// `shouldRenderStreaming`: a thinking-only stream chunk should not produce
|
||||||
|
// a chat bubble (thinking is rendered exclusively inside the ExecutionGraph).
|
||||||
const streamThinking = streamMsg ? extractThinking(streamMsg) : null;
|
const streamThinking = streamMsg ? extractThinking(streamMsg) : null;
|
||||||
const hasStreamThinking = !!streamThinking && streamThinking.trim().length > 0;
|
const hasStreamThinking = !!streamThinking && streamThinking.trim().length > 0;
|
||||||
const streamTools = streamMsg ? extractToolUse(streamMsg) : [];
|
const streamTools = streamMsg ? extractToolUse(streamMsg) : [];
|
||||||
@@ -182,7 +201,7 @@ export function Chat() {
|
|||||||
const hasStreamImages = streamImages.length > 0;
|
const hasStreamImages = streamImages.length > 0;
|
||||||
const hasStreamToolStatus = streamingTools.length > 0;
|
const hasStreamToolStatus = streamingTools.length > 0;
|
||||||
const hasRunningStreamToolStatus = streamingTools.some((tool) => tool.status === 'running');
|
const hasRunningStreamToolStatus = streamingTools.some((tool) => tool.status === 'running');
|
||||||
const shouldRenderStreaming = sending && (hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus);
|
const shouldRenderStreaming = sending && (hasStreamText || hasStreamTools || hasStreamImages || hasStreamToolStatus);
|
||||||
const hasAnyStreamContent = hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus;
|
const hasAnyStreamContent = hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus;
|
||||||
|
|
||||||
const isEmpty = messages.length === 0 && !sending;
|
const isEmpty = messages.length === 0 && !sending;
|
||||||
@@ -236,7 +255,22 @@ export function Chat() {
|
|||||||
const hasToolActivity = segmentMessages.some((m) =>
|
const hasToolActivity = segmentMessages.some((m) =>
|
||||||
m.role === 'assistant' && extractToolUse(m).length > 0,
|
m.role === 'assistant' && extractToolUse(m).length > 0,
|
||||||
);
|
);
|
||||||
const hasFinalReply = segmentMessages.some((m) => {
|
// Locate the last tool-use message so we only count text messages that
|
||||||
|
// come AFTER all tool calls as "final reply". Intermediate narration
|
||||||
|
// messages (pure text, no tool_use) sit BEFORE tool calls and must not
|
||||||
|
// be misread as the concluding reply — otherwise `runStillExecutingTools`
|
||||||
|
// flips to false between tool rounds, collapsing the trailing
|
||||||
|
// "Thinking..." indicator during the brief gap before the next stream chunk.
|
||||||
|
let lastToolUseOffset = -1;
|
||||||
|
for (let i = segmentMessages.length - 1; i >= 0; i -= 1) {
|
||||||
|
const m = segmentMessages[i];
|
||||||
|
if (m.role === 'assistant' && extractToolUse(m).length > 0) {
|
||||||
|
lastToolUseOffset = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hasFinalReply = segmentMessages.some((m, i) => {
|
||||||
|
if (i <= lastToolUseOffset) return false;
|
||||||
if (m.role !== 'assistant') return false;
|
if (m.role !== 'assistant') return false;
|
||||||
if (extractText(m).trim().length === 0) return false;
|
if (extractText(m).trim().length === 0) return false;
|
||||||
const content = m.content;
|
const content = m.content;
|
||||||
@@ -293,22 +327,36 @@ export function Chat() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Show the streaming response as a separate bubble (not inside the
|
// Show the streaming response as a separate bubble (not inside the
|
||||||
// execution graph) once all tool calls have finished.
|
// execution graph) once tool activity has happened and the CURRENT stream
|
||||||
|
// chunk carries no tool_use block.
|
||||||
//
|
//
|
||||||
// Three signals indicate "tools finished, now streaming the reply":
|
// We use an optimistic promotion strategy because the distinguishing
|
||||||
// 1. `pendingFinal` — set by tool-result final events
|
// signal between "narration-before-next-tool" and "final reply" is not
|
||||||
// 2. `allToolsCompleted` — all entries in streamingTools are completed
|
// available during early deltas — both are text-only, both arrive after
|
||||||
// 3. `hasCompletedToolPhase` — historical messages (loaded by the poll)
|
// `hasToolActivity` has flipped true. Any of these signals opens the
|
||||||
// contain tool_use blocks, meaning the Gateway executed tools
|
// promotion gate:
|
||||||
// server-side without sending streaming tool events to the client.
|
// 1. `pendingFinal` — tool-result final just fired; next text is
|
||||||
// During intermediate narration (before reply), stripProcessMessagePrefix
|
// (almost always) the final reply.
|
||||||
// will produce an empty trimmedReplyText, so the graph stays active.
|
// 2. `allToolsCompleted` — every client-tracked tool entry reached
|
||||||
|
// `completed` state.
|
||||||
|
// 3. `hasToolActivity` — at least one prior tool_use exists in the
|
||||||
|
// segment, i.e. we're past the first tool round.
|
||||||
|
//
|
||||||
|
// Demotion happens the moment a tool_use block appears in the streaming
|
||||||
|
// message (`streamTools.length > 0`) OR a tool transitions back to
|
||||||
|
// `running`. When demoted, the stream re-renders inside the graph as a
|
||||||
|
// narration step. A brief flicker when narration turns into the next
|
||||||
|
// tool round is inherent to optimistic promotion and is accepted.
|
||||||
|
//
|
||||||
|
// Earlier iterations tried restricting this gate to only
|
||||||
|
// `pendingFinal || allToolsCompleted` to protect the trailing
|
||||||
|
// "Thinking..." indicator. That check is real, but belongs in the
|
||||||
|
// `suppressThinking` coupling below — not here. With the coupling
|
||||||
|
// fixed, the three-signal gate gives the correct bubble placement for
|
||||||
|
// both narration and final reply.
|
||||||
const allToolsCompleted = streamingTools.length > 0 && !hasRunningStreamToolStatus;
|
const allToolsCompleted = streamingTools.length > 0 && !hasRunningStreamToolStatus;
|
||||||
const hasCompletedToolPhase = segmentMessages.some((msg) =>
|
|
||||||
msg.role === 'assistant' && extractToolUse(msg).length > 0,
|
|
||||||
);
|
|
||||||
const rawStreamingReplyCandidate = isLatestOpenRun
|
const rawStreamingReplyCandidate = isLatestOpenRun
|
||||||
&& (pendingFinal || allToolsCompleted || hasCompletedToolPhase)
|
&& (pendingFinal || allToolsCompleted || hasToolActivity)
|
||||||
&& (hasStreamText || hasStreamImages)
|
&& (hasStreamText || hasStreamImages)
|
||||||
&& streamTools.length === 0
|
&& streamTools.length === 0
|
||||||
&& !hasRunningStreamToolStatus;
|
&& !hasRunningStreamToolStatus;
|
||||||
@@ -341,6 +389,7 @@ export function Chat() {
|
|||||||
steps: [],
|
steps: [],
|
||||||
messageStepTexts: [],
|
messageStepTexts: [],
|
||||||
streamingReplyText: null,
|
streamingReplyText: null,
|
||||||
|
suppressThinking: false,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
const cached = graphStepCache[runKey];
|
const cached = graphStepCache[runKey];
|
||||||
@@ -362,6 +411,7 @@ export function Chat() {
|
|||||||
steps: cleanedSteps,
|
steps: cleanedSteps,
|
||||||
messageStepTexts: getPrimaryMessageStepTexts(cleanedSteps),
|
messageStepTexts: getPrimaryMessageStepTexts(cleanedSteps),
|
||||||
streamingReplyText: null,
|
streamingReplyText: null,
|
||||||
|
suppressThinking: false,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +445,23 @@ export function Chat() {
|
|||||||
// uncontrolled path before the controlled `expanded` override could kick in.
|
// uncontrolled path before the controlled `expanded` override could kick in.
|
||||||
const cardActive = isLatestOpenRun;
|
const cardActive = isLatestOpenRun;
|
||||||
|
|
||||||
|
// Suppress the trailing "Thinking..." indicator only when the live stream is
|
||||||
|
// currently rendered AS a streaming step inside this card's graph. In
|
||||||
|
// that case the streaming step itself is the activity signal, and the
|
||||||
|
// separate trailing indicator would be redundant.
|
||||||
|
// - streamingReplyText != null: stream is promoted to a bubble → graph
|
||||||
|
// has no live step of its own → DO show the trailing indicator so the
|
||||||
|
// user still sees progress in the graph (indicator rendered above the
|
||||||
|
// bubble).
|
||||||
|
// - no stream content at all (the gap between tool rounds): graph also
|
||||||
|
// has no live step → DO show the indicator — this is the very case
|
||||||
|
// the indicator exists for.
|
||||||
|
// - stream IS in graph (e.g. tool_use is streaming): indicator is
|
||||||
|
// redundant → suppress.
|
||||||
|
const streamIsInGraph =
|
||||||
|
isLatestOpenRun && streamingReplyText == null && hasAnyStreamContent;
|
||||||
|
const suppressThinking = streamIsInGraph;
|
||||||
|
|
||||||
return [{
|
return [{
|
||||||
triggerIndex: idx,
|
triggerIndex: idx,
|
||||||
replyIndex,
|
replyIndex,
|
||||||
@@ -405,6 +472,7 @@ export function Chat() {
|
|||||||
steps,
|
steps,
|
||||||
messageStepTexts: getPrimaryMessageStepTexts(steps),
|
messageStepTexts: getPrimaryMessageStepTexts(steps),
|
||||||
streamingReplyText,
|
streamingReplyText,
|
||||||
|
suppressThinking,
|
||||||
}];
|
}];
|
||||||
});
|
});
|
||||||
const hasActiveExecutionGraph = userRunCards.some((card) => card.active);
|
const hasActiveExecutionGraph = userRunCards.some((card) => card.active);
|
||||||
@@ -558,7 +626,7 @@ export function Chat() {
|
|||||||
agentLabel={card.agentLabel}
|
agentLabel={card.agentLabel}
|
||||||
steps={card.steps}
|
steps={card.steps}
|
||||||
active={card.active}
|
active={card.active}
|
||||||
suppressThinking={card.streamingReplyText != null}
|
suppressThinking={card.suppressThinking}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onExpandedChange={(next) =>
|
onExpandedChange={(next) =>
|
||||||
setGraphExpandedOverrides((prev) => ({ ...prev, [runKey]: next }))
|
setGraphExpandedOverrides((prev) => ({ ...prev, [runKey]: next }))
|
||||||
|
|||||||
Reference in New Issue
Block a user