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:
cpacker
2026-02-04 23:37:45 -08:00
parent 48ccd8f220
commit 59a1e41308
4 changed files with 99 additions and 13 deletions

View File

@@ -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.

View File

@@ -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 = () => {

View File

@@ -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)
},

View File

@@ -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;