Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions research/docs/2026-02-15-ralph-dag-orchestration-blockedby.md

Large diffs are not rendered by default.

81 changes: 70 additions & 11 deletions src/ui/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1282,12 +1282,16 @@ function buildContentSegments(
tasksOffset?: number,
tasksExpanded?: boolean,
): ContentSegment[] {
// Separate HITL tools from regular tools:
// Separate HITL tools and sub-agent Task tools from regular tools:
// - Running/pending HITL tools are hidden (the dialog handles display)
// - Completed HITL tools are shown as compact inline question records
// - Task tools are hidden — sub-agents are shown via ParallelAgentsTree;
// individual tool traces are available in the ctrl+o detail view only.
const isHitlTool = (name: string) =>
name === "AskUserQuestion" || name === "question" || name === "ask_user";
const visibleToolCalls = toolCalls.filter(tc => !isHitlTool(tc.toolName));
const isSubAgentTool = (name: string) =>
name === "Task" || name === "task";
const visibleToolCalls = toolCalls.filter(tc => !isHitlTool(tc.toolName) && !isSubAgentTool(tc.toolName));
const completedHitlCalls = toolCalls.filter(tc => isHitlTool(tc.toolName) && tc.status === "completed");

// Build unified list of insertion points
Expand Down Expand Up @@ -1317,13 +1321,38 @@ function buildContentSegments(
});
}

// Add agents tree insertion (if agents exist and offset is defined)
if (agents && agents.length > 0 && agentsOffset !== undefined) {
insertions.push({
offset: agentsOffset,
segment: { type: "agents", agents, key: "agents-tree" },
consumesText: false,
});
// Add agents tree insertion(s). When sub-agents are spawned sequentially
// (with text between invocations), each group of concurrent agents is
// rendered as a separate tree at its chronological content offset.
if (agents && agents.length > 0) {
// Build a map from agent ID → content offset using the Task tool calls
const taskToolOffsets = new Map<string, number>();
for (const tc of toolCalls) {
if (tc.toolName === "Task" || tc.toolName === "task") {
taskToolOffsets.set(tc.id, tc.contentOffsetAtStart ?? agentsOffset ?? 0);
}
}

// Group agents by their content offset
const groups = new Map<number, ParallelAgent[]>();
for (const agent of agents) {
const offset = taskToolOffsets.get(agent.id) ?? agentsOffset ?? 0;
const group = groups.get(offset);
if (group) {
group.push(agent);
} else {
groups.set(offset, [agent]);
}
}

// Create a tree insertion for each group
for (const [offset, groupAgents] of groups) {
insertions.push({
offset,
segment: { type: "agents", agents: groupAgents, key: `agents-tree-${offset}` },
consumesText: false,
});
}
}

// Add task list insertion (if tasks exist and offset is defined)
Expand Down Expand Up @@ -1921,6 +1950,10 @@ export function ChatApp({
// Store current input when entering history mode
const savedInputRef = useRef<string>("");

// Track skills that have already shown the "loaded" UI indicator this session.
// Once a skill is loaded, subsequent invocations should not show the indicator again.
const loadedSkillsRef = useRef<Set<string>>(new Set());

// Refs for streaming message updates
const streamingMessageIdRef = useRef<string | null>(null);
// Ref to track when streaming started for duration calculation
Expand Down Expand Up @@ -2261,6 +2294,10 @@ export function ChatApp({
skillName: string,
_skillPath?: string
) => {
// Only show "loaded" indicator on the first invocation per session
if (loadedSkillsRef.current.has(skillName)) return;
loadedSkillsRef.current.add(skillName);

const skillLoad: MessageSkillLoad = {
skillName,
status: "loaded",
Expand Down Expand Up @@ -3152,6 +3189,23 @@ export function ChatApp({
}
// Handle streaming response if handler provided
if (onStreamMessage) {
// Finalize any previous streaming message before starting a new one.
// This prevents duplicate "Generating..." spinners when sendSilentMessage
// is called from an @mention handler that already created a placeholder.
const prevStreamingId = streamingMessageIdRef.current;
if (prevStreamingId) {
setMessagesWindowed((prev: ChatMessage[]) =>
prev.map((msg: ChatMessage) =>
msg.id === prevStreamingId && msg.streaming
? { ...msg, streaming: false }
: msg
).filter((msg: ChatMessage) =>
// Remove the previous placeholder if it has no content
!(msg.id === prevStreamingId && !msg.content.trim())
)
);
}

// Increment stream generation so stale handleComplete callbacks become no-ops
const currentGeneration = ++streamGenerationRef.current;
isStreamingRef.current = true;
Expand Down Expand Up @@ -3449,6 +3503,7 @@ export function ChatApp({
setTranscriptMode(false);
clearHistoryBuffer();
setTrimmedMessageCount(0);
loadedSkillsRef.current.clear();
}

// Handle clearMessages flag — persist history before clearing
Expand Down Expand Up @@ -3508,8 +3563,12 @@ export function ChatApp({
addMessage("assistant", result.message);
}

// Track skill load in message for UI indicator
if (result.skillLoaded) {
// Track skill load in message for UI indicator (only on first successful load per session;
// errors are always shown so the user sees the failure)
if (result.skillLoaded && (result.skillLoadError || !loadedSkillsRef.current.has(result.skillLoaded))) {
if (!result.skillLoadError) {
loadedSkillsRef.current.add(result.skillLoaded);
}
const skillLoad: MessageSkillLoad = {
skillName: result.skillLoaded,
status: result.skillLoadError ? "error" : "loaded",
Expand Down
10 changes: 9 additions & 1 deletion src/ui/commands/skill-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1689,7 +1689,15 @@ function createDiskSkillCommand(skill: DiskSkillDefinition): CommandDefinition {
return { success: true, skillLoaded: skill.name };
}
const expandedPrompt = expandArguments(body, skillArgs);
context.sendSilentMessage(expandedPrompt);
// Prepend a directive so the model acts on the already-expanded
// skill content rather than re-loading the raw skill via the SDK's
// built-in "skill" tool (which would lose the $ARGUMENTS expansion).
const directive =
`<skill-loaded name="${skill.name}">\n` +
`The "${skill.name}" skill has already been loaded with the user's arguments below. ` +
`Do NOT invoke the Skill tool for "${skill.name}" — follow the instructions directly.\n` +
`</skill-loaded>\n\n`;
context.sendSilentMessage(directive + expandedPrompt);
return { success: true, skillLoaded: skill.name };
},
};
Expand Down
20 changes: 0 additions & 20 deletions src/ui/components/parallel-agents-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -411,16 +411,6 @@ function AgentRow({ agent, isLast, compact, themeColors }: AgentRowProps): React
</text>
</box>
)}
{/* Result summary for completed agents */}
{isCompleted && agent.result && (
<box flexDirection="row">
<text style={{ fg: themeColors.muted }}>
{continuationPrefix}{SUB_STATUS_PAD}</text>
<text style={{ fg: themeColors.success }}>
{CONNECTOR.subStatus} {truncateText(agent.result, 60)}
</text>
</box>
)}
</box>
);
}
Expand Down Expand Up @@ -459,16 +449,6 @@ function AgentRow({ agent, isLast, compact, themeColors }: AgentRowProps): React
</text>
</box>
)}
{/* Result summary for completed agents */}
{isCompleted && agent.result && (
<box flexDirection="row">
<text style={{ fg: themeColors.muted }}>
{continuationPrefix}{SUB_STATUS_PAD}</text>
<text style={{ fg: themeColors.success }}>
{CONNECTOR.subStatus} {truncateText(agent.result, 60)}
</text>
</box>
)}
</box>
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/ui/components/tool-result.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ export function ToolResult({
// Skill tool: render SkillLoadIndicator directly, bypassing standard tool result layout
const normalizedToolName = toolName.toLowerCase();
if (normalizedToolName === "skill") {
const skillName = (input.skill as string) || "unknown";
const skillName = (input.skill as string) || (input.name as string) || "unknown";
const skillStatus: SkillLoadStatus =
status === "completed" ? "loaded" : status === "error" ? "error" : "loading";
const errorMessage = status === "error" && typeof output === "string" ? output : undefined;
Expand Down
Loading
Loading