mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 00:47:51 +00:00
Improve approval resume UX and simplify thinking indicator
This commit is contained in:
@@ -1883,6 +1883,37 @@ const AgentStudioPage = () => {
|
||||
setPendingExecApprovalsByAgentId,
|
||||
setUnscopedPendingExecApprovals,
|
||||
requestHistoryRefresh: (agentId) => loadAgentHistory(agentId),
|
||||
onAllowResolved: ({ approval, targetAgentId }) => {
|
||||
const scopedPending = (pendingExecApprovalsByAgentId[targetAgentId] ?? []).some(
|
||||
(pendingApproval) => pendingApproval.id !== approval.id
|
||||
);
|
||||
const targetSessionKey = approval.sessionKey?.trim() ?? "";
|
||||
const unscopedPending = unscopedPendingExecApprovals.some((pendingApproval) => {
|
||||
if (pendingApproval.id === approval.id) return false;
|
||||
const pendingAgentId = pendingApproval.agentId?.trim() ?? "";
|
||||
if (pendingAgentId && pendingAgentId === targetAgentId) return true;
|
||||
if (!targetSessionKey) return false;
|
||||
return (pendingApproval.sessionKey?.trim() ?? "") === targetSessionKey;
|
||||
});
|
||||
if (scopedPending || unscopedPending) return;
|
||||
const latest =
|
||||
stateRef.current.agents.find((entry) => entry.agentId === targetAgentId) ?? null;
|
||||
if (!latest) return;
|
||||
const pausedRunId =
|
||||
approvalPausedRunIdByAgentRef.current.get(targetAgentId)?.trim() ?? "";
|
||||
const nowMs = Date.now();
|
||||
dispatch({
|
||||
type: "updateAgent",
|
||||
agentId: targetAgentId,
|
||||
patch: {
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
lastActivityAt: nowMs,
|
||||
...(pausedRunId ? { runId: pausedRunId } : {}),
|
||||
...(latest.runStartedAt === null ? { runStartedAt: nowMs } : {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
onAllowed: async ({ approval, targetAgentId }) => {
|
||||
const pausedByAgent = approvalPausedRunIdByAgentRef.current;
|
||||
const pausedRunId = pausedByAgent.get(targetAgentId) ?? null;
|
||||
@@ -2093,6 +2124,13 @@ const AgentStudioPage = () => {
|
||||
clearTimeout: (id) => window.clearTimeout(id),
|
||||
isDisconnectLikeError: isGatewayDisconnectLikeError,
|
||||
logWarn: (message, meta) => console.warn(message, meta),
|
||||
shouldSuppressRunAbortedLine: ({ agentId, runId, stopReason }) => {
|
||||
if (stopReason !== "rpc") return false;
|
||||
const normalizedRunId = runId?.trim() ?? "";
|
||||
if (!normalizedRunId) return false;
|
||||
const pausedRunId = approvalPausedRunIdByAgentRef.current.get(agentId)?.trim() ?? "";
|
||||
return pausedRunId.length > 0 && pausedRunId === normalizedRunId;
|
||||
},
|
||||
updateSpecialLatestUpdate: (agentId, agent, message) => {
|
||||
void specialLatestUpdate.update(agentId, agent, message);
|
||||
},
|
||||
|
||||
@@ -25,6 +25,10 @@ export const resolveExecApprovalViaStudio = async (params: {
|
||||
setPendingExecApprovalsByAgentId: SetState<Record<string, PendingExecApproval[]>>;
|
||||
setUnscopedPendingExecApprovals: SetState<PendingExecApproval[]>;
|
||||
requestHistoryRefresh: (agentId: string) => Promise<void> | void;
|
||||
onAllowResolved?: (params: {
|
||||
approval: PendingExecApproval;
|
||||
targetAgentId: string;
|
||||
}) => Promise<void> | void;
|
||||
onAllowed?: (params: { approval: PendingExecApproval; targetAgentId: string }) => Promise<void> | void;
|
||||
isDisconnectLikeError: (error: unknown) => boolean;
|
||||
shouldTreatUnknownId?: (error: unknown) => boolean;
|
||||
@@ -120,6 +124,7 @@ export const resolveExecApprovalViaStudio = async (params: {
|
||||
if (!approval) return;
|
||||
const targetAgentId = resolveApprovalTargetAgentId(approval);
|
||||
if (!targetAgentId) return;
|
||||
await params.onAllowResolved?.({ approval, targetAgentId });
|
||||
|
||||
const latest = params.getLatestAgent(targetAgentId);
|
||||
const activeRunId = latest?.runId?.trim() ?? "";
|
||||
|
||||
@@ -14,7 +14,6 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { ChevronRight, Clock, Cog, Shuffle } from "lucide-react";
|
||||
import type { GatewayModelChoice } from "@/lib/gateway/models";
|
||||
import { isTraceMarkdown } from "@/lib/text/message-extract";
|
||||
import { rewriteMediaLinesToMarkdown } from "@/lib/text/media-markdown";
|
||||
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
|
||||
import { isNearBottom } from "@/lib/dom";
|
||||
@@ -24,8 +23,10 @@ import type {
|
||||
PendingExecApproval,
|
||||
} from "@/features/agents/approvals/types";
|
||||
import {
|
||||
buildAgentChatRenderBlocks,
|
||||
buildFinalAgentChatItems,
|
||||
summarizeToolLabel,
|
||||
type AssistantTraceEvent,
|
||||
type AgentChatItem,
|
||||
} from "./chatItems";
|
||||
import { EmptyStatePanel } from "./EmptyStatePanel";
|
||||
@@ -182,13 +183,20 @@ const ToolCallDetails = memo(function ToolCallDetails({
|
||||
line: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { summaryText, body } = summarizeToolLabel(line);
|
||||
const { summaryText, body, inlineOnly } = summarizeToolLabel(line);
|
||||
const resolvedClassName =
|
||||
className ??
|
||||
`w-full ${ASSISTANT_MAX_WIDTH_EXPANDED_CLASS} ${ASSISTANT_GUTTER_CLASS} self-start rounded-[8px] border border-border/70 bg-surface-3 px-2 py-1 text-[10px] text-muted-foreground`;
|
||||
if (inlineOnly) {
|
||||
return (
|
||||
<div className={resolvedClassName}>
|
||||
<div className="font-mono text-[10px] font-semibold tracking-[0.11em]">{summaryText}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<details
|
||||
className={
|
||||
className ??
|
||||
`w-full ${ASSISTANT_MAX_WIDTH_EXPANDED_CLASS} ${ASSISTANT_GUTTER_CLASS} self-start rounded-[8px] border border-border/70 bg-surface-3 px-2 py-1 text-[10px] text-muted-foreground`
|
||||
}
|
||||
className={resolvedClassName}
|
||||
>
|
||||
<summary className="cursor-pointer select-none font-mono text-[10px] font-semibold tracking-[0.11em]">
|
||||
{summaryText}
|
||||
@@ -205,20 +213,31 @@ const ToolCallDetails = memo(function ToolCallDetails({
|
||||
});
|
||||
|
||||
const ThinkingDetailsRow = memo(function ThinkingDetailsRow({
|
||||
events,
|
||||
thinkingText,
|
||||
toolLines = [],
|
||||
durationMs,
|
||||
showTyping,
|
||||
}: {
|
||||
events?: AssistantTraceEvent[];
|
||||
thinkingText?: string | null;
|
||||
toolLines?: string[];
|
||||
durationMs?: number;
|
||||
showTyping?: boolean;
|
||||
}) {
|
||||
const normalizedThinkingText = thinkingText?.trim() ?? "";
|
||||
const hasThinkingText = normalizedThinkingText.length > 0;
|
||||
const hasToolLines = toolLines.length > 0;
|
||||
if (!hasThinkingText && !hasToolLines) return null;
|
||||
const traceEvents = (() => {
|
||||
if (events && events.length > 0) return events;
|
||||
const normalizedThinkingText = thinkingText?.trim() ?? "";
|
||||
const next: AssistantTraceEvent[] = [];
|
||||
if (normalizedThinkingText) {
|
||||
next.push({ kind: "thinking", text: normalizedThinkingText });
|
||||
}
|
||||
for (const line of toolLines) {
|
||||
next.push({ kind: "tool", text: line });
|
||||
}
|
||||
return next;
|
||||
})();
|
||||
if (traceEvents.length === 0) return null;
|
||||
return (
|
||||
<details className="group rounded-[8px] border border-border/70 bg-surface-2 px-2 py-1.5 text-[10px] text-muted-foreground/80">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 opacity-65 [&::-webkit-details-marker]:hidden">
|
||||
@@ -242,22 +261,24 @@ const ThinkingDetailsRow = memo(function ThinkingDetailsRow({
|
||||
) : null}
|
||||
</span>
|
||||
</summary>
|
||||
{hasThinkingText ? (
|
||||
<div className="agent-markdown mt-2 min-w-0 pl-5 text-foreground/85">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{normalizedThinkingText}</ReactMarkdown>
|
||||
</div>
|
||||
) : null}
|
||||
{hasToolLines ? (
|
||||
<div className="mt-2 space-y-1.5 pl-5">
|
||||
{toolLines.map((line, index) => (
|
||||
<div className="mt-2 space-y-2 pl-5">
|
||||
{traceEvents.map((event, index) =>
|
||||
event.kind === "thinking" ? (
|
||||
<div
|
||||
key={`thinking-event-${index}-${event.text.slice(0, 48)}`}
|
||||
className="agent-markdown min-w-0 text-foreground/85"
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{event.text}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<ToolCallDetails
|
||||
key={`thinking-tool-${index}-${line.slice(0, 48)}`}
|
||||
line={line}
|
||||
key={`thinking-tool-${index}-${event.text.slice(0, 48)}`}
|
||||
line={event.text}
|
||||
className="rounded-[8px] border border-border/70 bg-surface-3 px-2 py-1 text-[10px] text-muted-foreground"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
});
|
||||
@@ -293,10 +314,10 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
|
||||
avatarUrl,
|
||||
name,
|
||||
timestampMs,
|
||||
thinkingEvents,
|
||||
thinkingText,
|
||||
thinkingToolLines,
|
||||
thinkingDurationMs,
|
||||
showTypingIndicator,
|
||||
contentText,
|
||||
streaming,
|
||||
}: {
|
||||
@@ -304,15 +325,19 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
|
||||
avatarUrl: string | null;
|
||||
name: string;
|
||||
timestampMs?: number;
|
||||
thinkingEvents?: AssistantTraceEvent[];
|
||||
thinkingText?: string | null;
|
||||
thinkingToolLines?: string[];
|
||||
thinkingDurationMs?: number;
|
||||
showTypingIndicator?: boolean;
|
||||
contentText?: string | null;
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
const resolvedTimestamp = typeof timestampMs === "number" ? timestampMs : null;
|
||||
const hasThinking = Boolean(thinkingText?.trim() || (thinkingToolLines?.length ?? 0) > 0);
|
||||
const hasThinking = Boolean(
|
||||
(thinkingEvents?.length ?? 0) > 0 ||
|
||||
thinkingText?.trim() ||
|
||||
(thinkingToolLines?.length ?? 0) > 0
|
||||
);
|
||||
const widthClass = hasThinking
|
||||
? ASSISTANT_MAX_WIDTH_EXPANDED_CLASS
|
||||
: resolveAssistantMaxWidthClass(contentText);
|
||||
@@ -344,7 +369,7 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
|
||||
data-testid="agent-typing-indicator"
|
||||
>
|
||||
<span className="font-mono text-[9px] font-semibold uppercase tracking-[0.12em]">
|
||||
{showTypingIndicator ? "Typing" : "Streaming"}
|
||||
Thinking
|
||||
</span>
|
||||
<span className="typing-dots" aria-hidden="true">
|
||||
<span />
|
||||
@@ -362,7 +387,7 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
|
||||
data-testid="agent-typing-indicator"
|
||||
>
|
||||
<span className="font-mono text-[9px] font-semibold uppercase tracking-[0.12em]">
|
||||
{showTypingIndicator ? "Typing" : "Streaming"}
|
||||
Thinking
|
||||
</span>
|
||||
<span className="typing-dots" aria-hidden="true">
|
||||
<span />
|
||||
@@ -374,6 +399,7 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
|
||||
|
||||
{hasThinking ? (
|
||||
<ThinkingDetailsRow
|
||||
events={thinkingEvents}
|
||||
thinkingText={thinkingText}
|
||||
toolLines={thinkingToolLines ?? []}
|
||||
durationMs={thinkingDurationMs}
|
||||
@@ -437,85 +463,7 @@ const AgentChatFinalItems = memo(function AgentChatFinalItems({
|
||||
running: boolean;
|
||||
runStartedAt: number | null;
|
||||
}) {
|
||||
let pendingThinking: AgentChatItem | null = null;
|
||||
const blocks: Array<
|
||||
| { kind: "user"; text: string; timestampMs?: number }
|
||||
| {
|
||||
kind: "assistant";
|
||||
text: string | null;
|
||||
timestampMs?: number;
|
||||
thinkingText?: string;
|
||||
thinkingToolLines: string[];
|
||||
thinkingDurationMs?: number;
|
||||
}
|
||||
| { kind: "tool"; text: string }
|
||||
> = [];
|
||||
let orphanToolLines: string[] = [];
|
||||
|
||||
const flushPendingThinking = () => {
|
||||
if (!pendingThinking || pendingThinking.kind !== "thinking") return;
|
||||
blocks.push({
|
||||
kind: "assistant",
|
||||
text: null,
|
||||
timestampMs: pendingThinking.timestampMs,
|
||||
thinkingText: pendingThinking.text,
|
||||
thinkingToolLines: [...orphanToolLines],
|
||||
thinkingDurationMs: pendingThinking.thinkingDurationMs,
|
||||
});
|
||||
pendingThinking = null;
|
||||
orphanToolLines = [];
|
||||
};
|
||||
|
||||
const flushOrphanToolLines = () => {
|
||||
if (orphanToolLines.length === 0) return;
|
||||
for (const line of orphanToolLines) {
|
||||
blocks.push({ kind: "tool", text: line });
|
||||
}
|
||||
orphanToolLines = [];
|
||||
};
|
||||
|
||||
for (const item of chatItems) {
|
||||
switch (item.kind) {
|
||||
case "thinking":
|
||||
flushPendingThinking();
|
||||
pendingThinking = item;
|
||||
break;
|
||||
case "user":
|
||||
flushPendingThinking();
|
||||
flushOrphanToolLines();
|
||||
blocks.push({ kind: "user", text: item.text, timestampMs: item.timestampMs });
|
||||
break;
|
||||
case "assistant":
|
||||
if (pendingThinking?.kind === "thinking") {
|
||||
blocks.push({
|
||||
kind: "assistant",
|
||||
text: item.text,
|
||||
timestampMs: item.timestampMs ?? pendingThinking.timestampMs,
|
||||
thinkingText: pendingThinking.text,
|
||||
thinkingToolLines: [...orphanToolLines],
|
||||
thinkingDurationMs: item.thinkingDurationMs ?? pendingThinking.thinkingDurationMs,
|
||||
});
|
||||
pendingThinking = null;
|
||||
orphanToolLines = [];
|
||||
} else {
|
||||
flushOrphanToolLines();
|
||||
blocks.push({
|
||||
kind: "assistant",
|
||||
text: item.text,
|
||||
timestampMs: item.timestampMs,
|
||||
thinkingToolLines: [],
|
||||
thinkingDurationMs: item.thinkingDurationMs,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "tool":
|
||||
orphanToolLines.push(item.text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
flushPendingThinking();
|
||||
flushOrphanToolLines();
|
||||
const blocks = buildAgentChatRenderBlocks(chatItems);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -529,14 +477,6 @@ const AgentChatFinalItems = memo(function AgentChatFinalItems({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (block.kind === "tool") {
|
||||
return (
|
||||
<ToolCallDetails
|
||||
key={`chat-${agentId}-tool-${index}`}
|
||||
line={block.text}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const streaming = running && index === blocks.length - 1 && !block.text;
|
||||
return (
|
||||
<AssistantMessageCard
|
||||
@@ -545,8 +485,7 @@ const AgentChatFinalItems = memo(function AgentChatFinalItems({
|
||||
avatarUrl={avatarUrl}
|
||||
name={name}
|
||||
timestampMs={block.timestampMs ?? (streaming ? runStartedAt ?? undefined : undefined)}
|
||||
thinkingText={block.thinkingText ?? null}
|
||||
thinkingToolLines={block.thinkingToolLines}
|
||||
thinkingEvents={block.traceEvents}
|
||||
thinkingDurationMs={block.thinkingDurationMs}
|
||||
contentText={block.text}
|
||||
streaming={streaming}
|
||||
@@ -762,7 +701,6 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
|
||||
? Math.max(0, nowMs - runStartedAt)
|
||||
: undefined
|
||||
}
|
||||
showTypingIndicator={showTypingIndicator}
|
||||
contentText={liveAssistantText || null}
|
||||
streaming={status === "running"}
|
||||
/>
|
||||
@@ -919,7 +857,6 @@ export const AgentChatPanel = ({
|
||||
if (agent.draft === plainDraftRef.current) return;
|
||||
if (agent.draft.length !== 0) return;
|
||||
plainDraftRef.current = "";
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setDraftValue("");
|
||||
}, [agent.agentId, agent.draft, agent.sessionKey]);
|
||||
|
||||
@@ -980,38 +917,8 @@ export const AgentChatPanel = ({
|
||||
running && agent.streamText ? normalizeAssistantDisplayText(agent.streamText) : "";
|
||||
const liveThinkingText =
|
||||
running && agent.showThinkingTraces && agent.thinkingTrace ? agent.thinkingTrace.trim() : "";
|
||||
const hasLiveAssistantText = Boolean(liveAssistantText.trim());
|
||||
const hasVisibleLiveThinking = Boolean(liveThinkingText.trim());
|
||||
const latestUserOutputIndex = useMemo(() => {
|
||||
let latestUserIndex = -1;
|
||||
for (let index = agent.outputLines.length - 1; index >= 0; index -= 1) {
|
||||
const line = agent.outputLines[index]?.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith(">")) {
|
||||
latestUserIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return latestUserIndex;
|
||||
}, [agent.outputLines]);
|
||||
const hasSavedThinkingSinceLatestUser = useMemo(() => {
|
||||
if (!agent.showThinkingTraces || latestUserOutputIndex < 0) return false;
|
||||
for (
|
||||
let index = latestUserOutputIndex + 1;
|
||||
index < agent.outputLines.length;
|
||||
index += 1
|
||||
) {
|
||||
if (isTraceMarkdown(agent.outputLines[index] ?? "")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [agent.outputLines, agent.showThinkingTraces, latestUserOutputIndex]);
|
||||
const showTypingIndicator =
|
||||
running &&
|
||||
!hasLiveAssistantText &&
|
||||
!hasVisibleLiveThinking &&
|
||||
!hasSavedThinkingSinceLatestUser;
|
||||
const showTypingIndicator = running && !hasVisibleLiveThinking;
|
||||
|
||||
const modelOptions = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -21,6 +21,20 @@ export type AgentChatItem =
|
||||
| { kind: "tool"; text: string; timestampMs?: number }
|
||||
| { kind: "thinking"; text: string; live?: boolean; timestampMs?: number; thinkingDurationMs?: number };
|
||||
|
||||
export type AssistantTraceEvent =
|
||||
| { kind: "thinking"; text: string }
|
||||
| { kind: "tool"; text: string };
|
||||
|
||||
export type AgentChatRenderBlock =
|
||||
| { kind: "user"; text: string; timestampMs?: number }
|
||||
| {
|
||||
kind: "assistant";
|
||||
text: string | null;
|
||||
timestampMs?: number;
|
||||
thinkingDurationMs?: number;
|
||||
traceEvents: AssistantTraceEvent[];
|
||||
};
|
||||
|
||||
export type BuildAgentChatItemsInput = {
|
||||
outputLines: string[];
|
||||
streamText: string | null;
|
||||
@@ -269,6 +283,128 @@ export const buildAgentChatItems = ({
|
||||
return items;
|
||||
};
|
||||
|
||||
const mergeIncrementalText = (existing: string, next: string): string => {
|
||||
if (existing === next) return existing;
|
||||
if (next.startsWith(existing)) return next;
|
||||
if (existing.startsWith(next)) return existing;
|
||||
return `${existing}\n\n${next}`;
|
||||
};
|
||||
|
||||
const appendThinkingTraceEvent = (events: AssistantTraceEvent[], text: string) => {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) return;
|
||||
const previous = events[events.length - 1];
|
||||
if (!previous || previous.kind !== "thinking") {
|
||||
events.push({ kind: "thinking", text: normalized });
|
||||
return;
|
||||
}
|
||||
previous.text = mergeIncrementalText(previous.text, normalized);
|
||||
};
|
||||
|
||||
const hasMismatchedTimestamps = (
|
||||
left?: number,
|
||||
right?: number
|
||||
): boolean => {
|
||||
if (typeof left !== "number" || typeof right !== "number") return false;
|
||||
return left !== right;
|
||||
};
|
||||
|
||||
export const buildAgentChatRenderBlocks = (
|
||||
chatItems: AgentChatItem[]
|
||||
): AgentChatRenderBlock[] => {
|
||||
const blocks: AgentChatRenderBlock[] = [];
|
||||
let currentAssistant: Extract<AgentChatRenderBlock, { kind: "assistant" }> | null = null;
|
||||
|
||||
const flushAssistant = () => {
|
||||
if (!currentAssistant) return;
|
||||
if (currentAssistant.text || currentAssistant.traceEvents.length > 0) {
|
||||
blocks.push(currentAssistant);
|
||||
}
|
||||
currentAssistant = null;
|
||||
};
|
||||
|
||||
const ensureAssistant = (meta?: {
|
||||
timestampMs?: number;
|
||||
thinkingDurationMs?: number;
|
||||
}) => {
|
||||
if (!currentAssistant) {
|
||||
currentAssistant = {
|
||||
kind: "assistant",
|
||||
text: null,
|
||||
traceEvents: [],
|
||||
...(typeof meta?.timestampMs === "number" ? { timestampMs: meta.timestampMs } : {}),
|
||||
...(typeof meta?.thinkingDurationMs === "number"
|
||||
? { thinkingDurationMs: meta.thinkingDurationMs }
|
||||
: {}),
|
||||
};
|
||||
return currentAssistant;
|
||||
}
|
||||
if (
|
||||
currentAssistant.text &&
|
||||
hasMismatchedTimestamps(currentAssistant.timestampMs, meta?.timestampMs)
|
||||
) {
|
||||
flushAssistant();
|
||||
currentAssistant = {
|
||||
kind: "assistant",
|
||||
text: null,
|
||||
traceEvents: [],
|
||||
...(typeof meta?.timestampMs === "number" ? { timestampMs: meta.timestampMs } : {}),
|
||||
...(typeof meta?.thinkingDurationMs === "number"
|
||||
? { thinkingDurationMs: meta.thinkingDurationMs }
|
||||
: {}),
|
||||
};
|
||||
return currentAssistant;
|
||||
}
|
||||
if (
|
||||
typeof currentAssistant.timestampMs !== "number" &&
|
||||
typeof meta?.timestampMs === "number"
|
||||
) {
|
||||
currentAssistant.timestampMs = meta.timestampMs;
|
||||
}
|
||||
if (typeof meta?.thinkingDurationMs === "number") {
|
||||
currentAssistant.thinkingDurationMs = meta.thinkingDurationMs;
|
||||
}
|
||||
return currentAssistant;
|
||||
};
|
||||
|
||||
for (const item of chatItems) {
|
||||
if (item.kind === "user") {
|
||||
flushAssistant();
|
||||
blocks.push({ kind: "user", text: item.text, timestampMs: item.timestampMs });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.kind === "thinking") {
|
||||
const assistant = ensureAssistant({
|
||||
timestampMs: item.timestampMs,
|
||||
thinkingDurationMs: item.thinkingDurationMs,
|
||||
});
|
||||
appendThinkingTraceEvent(assistant.traceEvents, item.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.kind === "tool") {
|
||||
const assistant = ensureAssistant({ timestampMs: item.timestampMs });
|
||||
assistant.traceEvents.push({ kind: "tool", text: item.text });
|
||||
continue;
|
||||
}
|
||||
|
||||
const assistant = ensureAssistant({
|
||||
timestampMs: item.timestampMs,
|
||||
thinkingDurationMs: item.thinkingDurationMs,
|
||||
});
|
||||
const normalized = item.text.trim();
|
||||
if (!normalized) continue;
|
||||
assistant.text =
|
||||
typeof assistant.text === "string"
|
||||
? mergeIncrementalText(assistant.text, normalized)
|
||||
: normalized;
|
||||
}
|
||||
|
||||
flushAssistant();
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const stripTrailingToolCallId = (
|
||||
label: string
|
||||
): { toolLabel: string; toolCallId: string | null } => {
|
||||
@@ -312,6 +448,8 @@ const extractFirstCodeBlockLine = (body: string): string | null => {
|
||||
const extractToolArgSummary = (body: string): string | null => {
|
||||
const matchers: Array<[RegExp, (m: RegExpMatchArray) => string | null]> = [
|
||||
[/"command"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)],
|
||||
[/"file_path"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)],
|
||||
[/"filePath"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)],
|
||||
[/"path"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)],
|
||||
[/"url"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)],
|
||||
];
|
||||
@@ -323,12 +461,22 @@ const extractToolArgSummary = (body: string): string | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const summarizeToolLabel = (line: string): { summaryText: string; body: string } => {
|
||||
export const summarizeToolLabel = (
|
||||
line: string
|
||||
): { summaryText: string; body: string; inlineOnly?: boolean } => {
|
||||
const parsed = parseToolMarkdown(line);
|
||||
const { toolLabel } = stripTrailingToolCallId(parsed.label);
|
||||
const toolName = toDisplayToolName(toolLabel).toUpperCase();
|
||||
const metaLine = parsed.kind === "result" ? extractToolMetaLine(parsed.body) : null;
|
||||
const argSummary = parsed.kind === "call" ? extractToolArgSummary(parsed.body) : null;
|
||||
const toolIsRead = toolName === "READ";
|
||||
if (toolIsRead && parsed.kind === "call" && argSummary) {
|
||||
return {
|
||||
summaryText: `read ${argSummary}`,
|
||||
body: "",
|
||||
inlineOnly: true,
|
||||
};
|
||||
}
|
||||
const suffix = metaLine ?? argSummary;
|
||||
const toolIsExec = toolName === "EXEC";
|
||||
const execSummary =
|
||||
|
||||
@@ -63,6 +63,12 @@ export type GatewayRuntimeEventHandlerDeps = {
|
||||
|
||||
isDisconnectLikeError: (err: unknown) => boolean;
|
||||
logWarn?: (message: string, meta?: unknown) => void;
|
||||
shouldSuppressRunAbortedLine?: (params: {
|
||||
agentId: string;
|
||||
runId: string | null;
|
||||
sessionKey: string;
|
||||
stopReason: string | null;
|
||||
}) => boolean;
|
||||
|
||||
updateSpecialLatestUpdate: (agentId: string, agent: AgentState, message: string) => void;
|
||||
};
|
||||
@@ -800,14 +806,23 @@ export function createGatewayRuntimeEventHandler(
|
||||
}
|
||||
|
||||
if (payload.state === "aborted") {
|
||||
dispatchOutput(agentId, "Run aborted.", {
|
||||
source: "runtime-chat",
|
||||
runId: payload.runId ?? null,
|
||||
sessionKey: payload.sessionKey,
|
||||
timestampMs: now(),
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
});
|
||||
const suppressAbortedLine =
|
||||
deps.shouldSuppressRunAbortedLine?.({
|
||||
agentId,
|
||||
runId: payload.runId ?? null,
|
||||
sessionKey: payload.sessionKey,
|
||||
stopReason: payload.stopReason?.trim() ?? null,
|
||||
}) ?? false;
|
||||
if (!suppressAbortedLine) {
|
||||
dispatchOutput(agentId, "Run aborted.", {
|
||||
source: "runtime-chat",
|
||||
runId: payload.runId ?? null,
|
||||
sessionKey: payload.sessionKey,
|
||||
timestampMs: now(),
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
});
|
||||
}
|
||||
applyRuntimePolicyIntents(chatIntents, { agentForLatestUpdate: agent });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ export type ChatEventPayload = {
|
||||
sessionKey: string;
|
||||
state: "delta" | "final" | "aborted" | "error";
|
||||
seq?: number;
|
||||
stopReason?: string;
|
||||
message?: unknown;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
@@ -209,7 +209,7 @@ describe("AgentChatPanel controls", () => {
|
||||
expect(stopButton.parentElement).toHaveAttribute("title", stopDisabledReason);
|
||||
});
|
||||
|
||||
it("shows_typing_indicator_while_running_before_stream_text", () => {
|
||||
it("shows_thinking_indicator_while_running_before_stream_text", () => {
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: { ...createAgent(), status: "running", outputLines: ["> test"] },
|
||||
@@ -229,10 +229,10 @@ describe("AgentChatPanel controls", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("agent-typing-indicator")).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("agent-typing-indicator")).getByText("Typing")).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("agent-typing-indicator")).getByText("Thinking")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides_typing_indicator_after_stream_starts", () => {
|
||||
it("shows_thinking_indicator_after_stream_starts", () => {
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: {
|
||||
@@ -257,10 +257,10 @@ describe("AgentChatPanel controls", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("agent-typing-indicator")).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("agent-typing-indicator")).getByText("Streaming")).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("agent-typing-indicator")).getByText("Thinking")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides_typing_indicator_when_thinking_trace_has_started", () => {
|
||||
it("keeps_thinking_animation_visible_when_saved_thinking_exists", () => {
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: {
|
||||
@@ -283,7 +283,7 @@ describe("AgentChatPanel controls", () => {
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("agent-typing-indicator")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("agent-typing-indicator").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders thinking row collapsed by default", () => {
|
||||
|
||||
@@ -138,4 +138,37 @@ describe("AgentChatPanel markdown rendering", () => {
|
||||
expect(thinkingDetails).toContainElement(summary);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders read tool calls as inline path labels instead of collapsible JSON blocks", () => {
|
||||
const readToolCall = formatToolCallMarkdown({
|
||||
id: "call_read_1",
|
||||
name: "read",
|
||||
arguments: { file_path: "/tmp/README.md" },
|
||||
});
|
||||
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: {
|
||||
...createAgent(),
|
||||
outputLines: [formatThinkingMarkdown("Reviewing docs"), readToolCall],
|
||||
},
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText("read /tmp/README.md")).toBeInTheDocument();
|
||||
expect(screen.queryByText("read /tmp/README.md", { selector: "summary" })).toBeNull();
|
||||
expect(screen.queryByText(/"file_path"/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildAgentChatItems, buildFinalAgentChatItems, summarizeToolLabel } from "@/features/agents/components/chatItems";
|
||||
import {
|
||||
buildAgentChatItems,
|
||||
buildAgentChatRenderBlocks,
|
||||
buildFinalAgentChatItems,
|
||||
summarizeToolLabel,
|
||||
} from "@/features/agents/components/chatItems";
|
||||
import { formatMetaMarkdown, formatThinkingMarkdown, formatToolCallMarkdown, formatToolResultMarkdown } from "@/lib/text/message-extract";
|
||||
|
||||
describe("buildAgentChatItems", () => {
|
||||
@@ -247,4 +252,87 @@ describe("summarizeToolLabel", () => {
|
||||
expect(resultSummary).toContain("exit 0");
|
||||
expect(resultSummary).not.toContain("call_");
|
||||
});
|
||||
|
||||
it("renders read file calls as inline path labels without JSON body", () => {
|
||||
const toolCallLine = formatToolCallMarkdown({
|
||||
id: "call_read_1",
|
||||
name: "read",
|
||||
arguments: { file_path: "/Users/georgepickett/openclaw/shared/openclaw-agent-home/README.md" },
|
||||
});
|
||||
|
||||
const summary = summarizeToolLabel(toolCallLine);
|
||||
expect(summary.summaryText).toBe(
|
||||
"read /Users/georgepickett/openclaw/shared/openclaw-agent-home/README.md"
|
||||
);
|
||||
expect(summary.inlineOnly).toBe(true);
|
||||
expect(summary.body).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAgentChatRenderBlocks", () => {
|
||||
it("groups thinking and tool events into one assistant block in original order", () => {
|
||||
const toolCallLine = formatToolCallMarkdown({
|
||||
id: "call_1",
|
||||
name: "exec",
|
||||
arguments: { command: "pwd" },
|
||||
});
|
||||
const toolResultLine = formatToolResultMarkdown({
|
||||
toolCallId: "call_1",
|
||||
toolName: "exec",
|
||||
details: { status: "completed", exitCode: 0 },
|
||||
text: "/repo",
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const blocks = buildAgentChatRenderBlocks([
|
||||
{ kind: "thinking", text: "_plan before tool_", timestampMs: 100 },
|
||||
{ kind: "tool", text: toolCallLine, timestampMs: 101 },
|
||||
{ kind: "thinking", text: "_plan after tool_", timestampMs: 102 },
|
||||
{ kind: "tool", text: toolResultLine, timestampMs: 103 },
|
||||
{ kind: "assistant", text: "done", timestampMs: 104 },
|
||||
]);
|
||||
|
||||
expect(blocks).toEqual([
|
||||
{
|
||||
kind: "assistant",
|
||||
text: "done",
|
||||
timestampMs: 100,
|
||||
traceEvents: [
|
||||
{ kind: "thinking", text: "_plan before tool_" },
|
||||
{ kind: "tool", text: toolCallLine },
|
||||
{ kind: "thinking", text: "_plan after tool_" },
|
||||
{ kind: "tool", text: toolResultLine },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("starts a new assistant block after a user turn", () => {
|
||||
const blocks = buildAgentChatRenderBlocks([
|
||||
{ kind: "thinking", text: "_first plan_", timestampMs: 10 },
|
||||
{ kind: "assistant", text: "first answer", timestampMs: 11 },
|
||||
{ kind: "user", text: "next question", timestampMs: 12 },
|
||||
{ kind: "thinking", text: "_second plan_", timestampMs: 13 },
|
||||
{ kind: "assistant", text: "second answer", timestampMs: 14 },
|
||||
]);
|
||||
|
||||
expect(blocks.map((block) => block.kind)).toEqual(["assistant", "user", "assistant"]);
|
||||
});
|
||||
|
||||
it("merges adjacent incremental thinking updates", () => {
|
||||
const blocks = buildAgentChatRenderBlocks([
|
||||
{ kind: "thinking", text: "_a_", timestampMs: 10 },
|
||||
{ kind: "thinking", text: "_a_\n\n_b_", timestampMs: 10 },
|
||||
{ kind: "assistant", text: "answer", timestampMs: 10 },
|
||||
]);
|
||||
|
||||
expect(blocks).toEqual([
|
||||
{
|
||||
kind: "assistant",
|
||||
text: "answer",
|
||||
timestampMs: 10,
|
||||
traceEvents: [{ kind: "thinking", text: "_a_\n\n_b_" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,7 @@ describe("execApprovalResolveOperation", () => {
|
||||
});
|
||||
const unscopedApprovals = createState<PendingExecApproval[]>([]);
|
||||
const requestHistoryRefresh = vi.fn();
|
||||
const onAllowResolved = vi.fn();
|
||||
const onAllowed = vi.fn();
|
||||
|
||||
await resolveExecApprovalViaStudio({
|
||||
@@ -73,6 +74,7 @@ describe("execApprovalResolveOperation", () => {
|
||||
setPendingExecApprovalsByAgentId: approvalsByAgentId.set,
|
||||
setUnscopedPendingExecApprovals: unscopedApprovals.set,
|
||||
requestHistoryRefresh,
|
||||
onAllowResolved,
|
||||
onAllowed,
|
||||
isDisconnectLikeError: () => false,
|
||||
});
|
||||
@@ -82,8 +84,12 @@ describe("execApprovalResolveOperation", () => {
|
||||
|
||||
expect(approvalsByAgentId.get()).toEqual({});
|
||||
expect(unscopedApprovals.get()).toEqual([]);
|
||||
expect(onAllowResolved).toHaveBeenCalledWith({ approval, targetAgentId: "a1" });
|
||||
expect(requestHistoryRefresh).toHaveBeenCalledWith("a1");
|
||||
expect(onAllowed).toHaveBeenCalledWith({ approval, targetAgentId: "a1" });
|
||||
expect(onAllowResolved.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
requestHistoryRefresh.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it("treats unknown approval id as already removed", async () => {
|
||||
|
||||
@@ -807,6 +807,62 @@ describe("gateway runtime event handler (chat)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses aborted status line when abort is an approval pause", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const dispatch = vi.fn();
|
||||
const shouldSuppressRunAbortedLine = vi.fn(({ runId, stopReason }) => {
|
||||
return runId === "run-1" && stopReason === "rpc";
|
||||
});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
shouldSuppressRunAbortedLine,
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "aborted",
|
||||
stopReason: "rpc",
|
||||
message: { role: "assistant", content: "" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(shouldSuppressRunAbortedLine).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
stopReason: "rpc",
|
||||
})
|
||||
);
|
||||
expect(dispatch).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "appendOutput", agentId: "agent-1", line: "Run aborted." })
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: expect.objectContaining({ status: "idle" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores late delta chat events after a run has already finalized", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const queueLivePatch = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user