Improve approval resume UX and simplify thinking indicator

This commit is contained in:
George Pickett
2026-02-17 14:09:24 -08:00
parent 8035b32dd8
commit 538e899c69
11 changed files with 463 additions and 166 deletions
+38
View File
@@ -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() ?? "";
+57 -150
View File
@@ -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(
() =>
+149 -1
View File
@@ -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;
};
+6 -6
View File
@@ -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();
});
});
+89 -1
View File
@@ -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();