fix: tool call dot phases and colors for clearer execution feedback
Tool call dot/phase behavior: - Tool calls start in streaming phase (static grey) instead of ready - Added approvalsPending flag to prevent server tools from blinking during approval - Server tools promoted to running only after approvals complete Tool dot colors: - Fixed missing # in statusWarning hex literal - Running phase uses grey blinking instead of yellow Args rendering + crash fixes: - Args considered "complete" by JSON parseability, not just phase - Coerce argsText to string to avoid runtime errors - Fixed TDZ error from shadowed variable - Ready phase only blinks once streaming finished Behavioral fixes: - Server-side tools don't show "Cancelled" after approvals - Mixed server/client tools: server stays static during approval, blinks after - Args remain visible once complete 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com>
This commit is contained in:
@@ -2742,6 +2742,45 @@ export default function App({
|
||||
initialInput: Array<MessageCreate | ApprovalCreate>,
|
||||
options?: { allowReentry?: boolean; submissionGeneration?: number },
|
||||
): Promise<void> => {
|
||||
// Reset per-run approval tracking used by streaming UI.
|
||||
buffersRef.current.approvalsPending = false;
|
||||
if (buffersRef.current.serverToolCalls.size > 0) {
|
||||
let didPromote = false;
|
||||
for (const [toolCallId, toolInfo] of buffersRef.current
|
||||
.serverToolCalls) {
|
||||
const lineId = buffersRef.current.toolCallIdToLineId.get(toolCallId);
|
||||
if (!lineId) continue;
|
||||
const line = buffersRef.current.byId.get(lineId);
|
||||
if (!line || line.kind !== "tool_call" || line.phase === "finished") {
|
||||
continue;
|
||||
}
|
||||
const argsCandidate = toolInfo.toolArgs ?? "";
|
||||
const trimmed = argsCandidate.trim();
|
||||
let argsComplete = false;
|
||||
if (trimmed.length === 0) {
|
||||
argsComplete = true;
|
||||
} else {
|
||||
try {
|
||||
JSON.parse(argsCandidate);
|
||||
argsComplete = true;
|
||||
} catch {
|
||||
// Args still incomplete.
|
||||
}
|
||||
}
|
||||
if (argsComplete && line.phase !== "running") {
|
||||
const nextLine = {
|
||||
...line,
|
||||
phase: "running" as const,
|
||||
argsText: line.argsText ?? argsCandidate,
|
||||
};
|
||||
buffersRef.current.byId.set(lineId, nextLine);
|
||||
didPromote = true;
|
||||
}
|
||||
}
|
||||
if (didPromote) {
|
||||
refreshDerived();
|
||||
}
|
||||
}
|
||||
// Helper function for Ralph Wiggum mode continuation
|
||||
// Defined here to have access to buffersRef, processConversation via closure
|
||||
const handleRalphContinuation = () => {
|
||||
@@ -8737,7 +8776,7 @@ ${SYSTEM_REMINDER_CLOSE}
|
||||
refreshDerived();
|
||||
}
|
||||
toolResultsInFlightRef.current = true;
|
||||
await processConversation(input);
|
||||
await processConversation(input, { allowReentry: true });
|
||||
toolResultsInFlightRef.current = false;
|
||||
|
||||
// Clear any stale queued results from previous interrupts.
|
||||
|
||||
@@ -104,7 +104,12 @@ export const ToolCallMessage = memo(
|
||||
|
||||
// Parse and format the tool call
|
||||
const rawName = line.name ?? "?";
|
||||
const argsText = line.argsText ?? "...";
|
||||
const argsText =
|
||||
typeof line.argsText === "string"
|
||||
? line.argsText
|
||||
: line.argsText == null
|
||||
? ""
|
||||
: JSON.stringify(line.argsText);
|
||||
|
||||
// Task tool rendering decision:
|
||||
// - Cancelled/rejected: render as error tool call (won't appear in SubagentGroupDisplay)
|
||||
@@ -165,16 +170,35 @@ export const ToolCallMessage = memo(
|
||||
// - Phase "running"/"finished" or stream done: args complete, show formatted
|
||||
let args = "";
|
||||
if (!isQuestionTool(rawName)) {
|
||||
// Args are complete once running, finished, or stream is done
|
||||
const parseArgs = (): {
|
||||
formatted: ReturnType<typeof formatArgsDisplay> | null;
|
||||
parseable: boolean;
|
||||
} => {
|
||||
if (!argsText.trim()) {
|
||||
return { formatted: null, parseable: true };
|
||||
}
|
||||
try {
|
||||
const formatted = formatArgsDisplay(argsText, rawName);
|
||||
return { formatted, parseable: true };
|
||||
} catch {
|
||||
return { formatted: null, parseable: false };
|
||||
}
|
||||
};
|
||||
|
||||
// Args are complete once running/finished, stream done, or JSON is parseable.
|
||||
const { formatted, parseable } = parseArgs();
|
||||
const argsComplete =
|
||||
line.phase === "running" || line.phase === "finished" || !isStreaming;
|
||||
parseable ||
|
||||
line.phase === "running" ||
|
||||
line.phase === "finished" ||
|
||||
!isStreaming;
|
||||
|
||||
if (!argsComplete) {
|
||||
args = "(…)";
|
||||
} else {
|
||||
const formatted = formatArgsDisplay(argsText, rawName);
|
||||
const formattedArgs = formatted ?? formatArgsDisplay(argsText, rawName);
|
||||
// Normalize newlines to spaces to prevent forced line breaks
|
||||
const normalizedDisplay = formatted.display.replace(/\n/g, " ");
|
||||
const normalizedDisplay = formattedArgs.display.replace(/\n/g, " ");
|
||||
// For max 2 lines: boxWidth * 2, minus parens (2) and margin (2)
|
||||
const argsBoxWidth = rightWidth - displayName.length;
|
||||
const maxArgsChars = Math.max(0, argsBoxWidth * 2 - 4);
|
||||
@@ -206,7 +230,8 @@ export const ToolCallMessage = memo(
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
const dotShouldAnimate = line.phase === "ready" || line.phase === "running";
|
||||
const dotShouldAnimate =
|
||||
line.phase === "running" || (line.phase === "ready" && !isStreaming);
|
||||
|
||||
// Format result for display
|
||||
const getResultElement = () => {
|
||||
|
||||
@@ -47,7 +47,7 @@ export const brandColors = {
|
||||
textDisabled: "#46484A", // dark grey
|
||||
// status colors
|
||||
statusSuccess: "#64CF64", // green
|
||||
statusWarning: "FEE19C", // yellow
|
||||
statusWarning: "#FEE19C", // yellow
|
||||
statusError: "#F1689F", // red
|
||||
} as const;
|
||||
|
||||
@@ -126,8 +126,8 @@ const _colors = {
|
||||
tool: {
|
||||
pending: brandColors.textSecondary, // blinking dot (ready/waiting for approval)
|
||||
completed: brandColors.statusSuccess, // solid green dot (finished successfully)
|
||||
streaming: brandColors.textDisabled, // solid gray dot (streaming/in progress)
|
||||
running: brandColors.statusWarning, // blinking yellow dot (executing)
|
||||
streaming: brandColors.textSecondary, // solid gray dot (streaming/in progress)
|
||||
running: brandColors.textSecondary, // blinking gray dot (executing)
|
||||
error: brandColors.statusError, // solid red dot (failed)
|
||||
memoryName: brandColors.primaryAccent, // memory tool name highlight (matches thinking spinner)
|
||||
},
|
||||
|
||||
@@ -224,6 +224,8 @@ export type Buffers = {
|
||||
splitCounters: Map<string, number>; // tracks split count per original otid
|
||||
// Track server-side tool calls for hook triggering (toolCallId -> info)
|
||||
serverToolCalls: Map<string, ServerToolCallInfo>;
|
||||
// Track if this run has pending approvals (used to gate server tool phases)
|
||||
approvalsPending: boolean;
|
||||
// Agent ID for passing to hooks (needed for server-side tools like memory)
|
||||
agentId?: string;
|
||||
};
|
||||
@@ -250,6 +252,7 @@ export function createBuffers(agentId?: string): Buffers {
|
||||
tokenStreamingEnabled: false,
|
||||
splitCounters: new Map(),
|
||||
serverToolCalls: new Map(),
|
||||
approvalsPending: false,
|
||||
agentId,
|
||||
};
|
||||
}
|
||||
@@ -587,8 +590,10 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) {
|
||||
b.toolCallIdToLineId.set(toolCallId, id);
|
||||
}
|
||||
|
||||
// Tool calls should be "ready" (blinking) while pending execution
|
||||
const desiredPhase = "ready";
|
||||
// Tool calls start in "streaming" (static grey) while args stream in.
|
||||
// Approval requests move to "ready" (blinking), server tools move to
|
||||
// "running" once args are complete.
|
||||
const desiredPhase = "streaming";
|
||||
let line = ensure<ToolCallLine>(b, id, () => ({
|
||||
kind: "tool_call",
|
||||
id,
|
||||
@@ -612,7 +617,23 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) {
|
||||
chunk.message_type === "approval_request_message" &&
|
||||
line.phase !== "finished"
|
||||
) {
|
||||
b.byId.set(id, { ...line, phase: "ready" });
|
||||
b.approvalsPending = true;
|
||||
line = { ...line, phase: "ready" };
|
||||
b.byId.set(id, line);
|
||||
|
||||
// Downgrade any server tools to streaming while approvals are pending.
|
||||
for (const [toolCallId] of b.serverToolCalls) {
|
||||
const serverLineId = b.toolCallIdToLineId.get(toolCallId);
|
||||
if (!serverLineId) continue;
|
||||
const serverLine = b.byId.get(serverLineId);
|
||||
if (
|
||||
serverLine &&
|
||||
serverLine.kind === "tool_call" &&
|
||||
serverLine.phase === "running"
|
||||
) {
|
||||
b.byId.set(serverLineId, { ...serverLine, phase: "streaming" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if argsText is not empty, add it to the line (immutable update)
|
||||
@@ -622,6 +643,7 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) {
|
||||
...line,
|
||||
argsText: (line.argsText || "") + argsText,
|
||||
};
|
||||
line = updatedLine;
|
||||
b.byId.set(id, updatedLine);
|
||||
// Count tool call arguments as LLM output tokens
|
||||
b.tokenCount += argsText.length;
|
||||
|
||||
Reference in New Issue
Block a user