feat: Add task status/summary gen ui component

This commit is contained in:
bracesproul 2025-06-22 12:51:57 -07:00
parent 96298ad22b
commit ea8427a508
4 changed files with 99 additions and 60 deletions

View file

@ -23,6 +23,7 @@ import {
} from "../../../utils/current-task.js"; } from "../../../utils/current-task.js";
import { ToolMessage } from "@langchain/core/messages"; import { ToolMessage } from "@langchain/core/messages";
import { addTaskPlanToIssue } from "../../../utils/github/issue-task.js"; import { addTaskPlanToIssue } from "../../../utils/github/issue-task.js";
import { createSetTaskStatusToolFields } from "@open-swe/shared/open-swe/tools";
const logger = createLogger(LogLevel.INFO, "ProgressPlanStep"); const logger = createLogger(LogLevel.INFO, "ProgressPlanStep");
@ -40,26 +41,6 @@ Take all of this information, and determine whether or not you have completed th
Once you've determined the status of the current task, call the \`set_task_status\` tool. Once you've determined the status of the current task, call the \`set_task_status\` tool.
`; `;
const setTaskStatusToolSchema = z.object({
reasoning: z
.string()
.describe(
"A concise reasoning summary for the status of the current task, explaining why you think it is completed or not completed.",
),
task_status: z
.enum(["completed", "not_completed"])
.describe(
"The status of the current task, based on the reasoning provided.",
),
});
const setTaskStatusTool = {
name: "set_task_status",
description:
"The status of the current task, along with a concise reasoning summary to support the status.",
schema: setTaskStatusToolSchema,
};
const formatPrompt = (taskPlan: PlanItem[]): string => { const formatPrompt = (taskPlan: PlanItem[]): string => {
return systemPrompt.replace( return systemPrompt.replace(
"{PLAN_PROMPT}", "{PLAN_PROMPT}",
@ -71,6 +52,7 @@ export async function progressPlanStep(
state: GraphState, state: GraphState,
config: GraphConfig, config: GraphConfig,
): Promise<Command> { ): Promise<Command> {
const setTaskStatusTool = createSetTaskStatusToolFields();
const model = await loadModel(config, Task.PROGRESS_PLAN_CHECKER); const model = await loadModel(config, Task.PROGRESS_PLAN_CHECKER);
const modelWithTools = model.bindTools([setTaskStatusTool], { const modelWithTools = model.bindTools([setTaskStatusTool], {
tool_choice: setTaskStatusTool.name, tool_choice: setTaskStatusTool.name,
@ -109,7 +91,7 @@ Once you've determined the status of the current task, call the \`set_task_statu
} }
const isCompleted = const isCompleted =
(toolCall.args as z.infer<typeof setTaskStatusToolSchema>).task_status === (toolCall.args as z.infer<typeof setTaskStatusTool.schema>).task_status ===
"completed"; "completed";
const currentTask = getCurrentPlanItem(activePlanItems); const currentTask = getCurrentPlanItem(activePlanItems);
const toolMessage = new ToolMessage({ const toolMessage = new ToolMessage({

View file

@ -1,43 +1,44 @@
"use client"; "use client";
import "../app/globals.css";
import { useState } from "react"; import { useState } from "react";
import { import {
CheckCircle, CheckCircle,
Loader2, Loader2,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
MessageSquare,
FileText, FileText,
MinusCircle,
} from "lucide-react"; } from "lucide-react";
type TaskSummaryProps = { type TaskSummaryProps = {
status: "loading" | "generating" | "done"; status: "loading" | "generating" | "done";
completed?: boolean;
summary?: string; summary?: string;
reasoningText?: string;
summaryText?: string; summaryText?: string;
}; };
export function TaskSummary({ export function TaskSummary({
status, status,
completed,
summary, summary,
reasoningText,
summaryText, summaryText,
}: TaskSummaryProps) { }: TaskSummaryProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [showReasoning, setShowReasoning] = useState(false);
const [showSummary, setShowSummary] = useState(false); const [showSummary, setShowSummary] = useState(false);
const getStatusIcon = () => { const getStatusIcon = () => {
switch (status) { switch (status) {
case "loading": case "loading":
return ( return <div className="border-border size-3.5 rounded-full border" />;
<div className="h-3.5 w-3.5 rounded-full border border-gray-300" />
);
case "generating": case "generating":
return <Loader2 className="h-3.5 w-3.5 animate-spin text-gray-500" />; return (
<Loader2 className="text-muted-foreground size-3.5 animate-spin" />
);
case "done": case "done":
return <CheckCircle className="h-3.5 w-3.5 text-green-500" />; if (completed === false) {
return <MinusCircle className="size-3.5 text-amber-500" />;
}
return <CheckCircle className="size-3.5 text-green-500" />;
} }
}; };
@ -48,31 +49,17 @@ export function TaskSummary({
case "generating": case "generating":
return "Generating summary..."; return "Generating summary...";
case "done": case "done":
if (completed === false) {
return "Task not completed";
}
return "Task completed"; return "Task completed";
} }
}; };
return ( return (
<div className="overflow-hidden rounded-md border border-gray-200"> <div className="border-border overflow-hidden rounded-md border">
{reasoningText && (
<div className="border-b border-blue-100 bg-blue-50 p-2">
<button
onClick={() => setShowReasoning(!showReasoning)}
className="flex items-center gap-1 text-xs font-normal text-blue-700 hover:text-blue-800"
>
<MessageSquare className="h-3 w-3" />
{showReasoning ? "Hide reasoning" : "Show reasoning"}
</button>
{showReasoning && (
<p className="mt-1 text-xs font-normal text-blue-800">
{reasoningText}
</p>
)}
</div>
)}
<div <div
className={`flex items-center bg-gray-50 p-2 ${status === "done" && summary ? "cursor-pointer" : ""}`} className={`flex items-center border-b bg-gray-50 p-2 dark:bg-gray-800 ${status === "done" && summary ? "cursor-pointer" : ""}`}
onClick={ onClick={
status === "done" && summary status === "done" && summary
? () => setExpanded(!expanded) ? () => setExpanded(!expanded)
@ -80,40 +67,56 @@ export function TaskSummary({
} }
> >
{getStatusIcon()} {getStatusIcon()}
<span className="ml-2 flex-1 text-xs font-normal text-gray-800"> <span className="text-foreground/80 ml-2 flex-1 text-xs font-normal">
{getStatusText()} {getStatusText()}
</span> </span>
{status === "done" && summary && ( {status === "done" && summary && (
<button className="text-gray-500 hover:text-gray-700"> <button className="text-muted-foreground hover:text-foreground">
{expanded ? ( {expanded ? (
<ChevronUp className="h-3.5 w-3.5" /> <ChevronUp className="size-3.5" />
) : ( ) : (
<ChevronDown className="h-3.5 w-3.5" /> <ChevronDown className="size-3.5" />
)} )}
</button> </button>
)} )}
</div> </div>
{expanded && summary && status === "done" && ( {expanded && summary && status === "done" && (
<div className="border-t border-gray-200 p-2"> <div className="border-border border-t p-2">
<h3 className="mb-1 text-xs font-normal text-gray-500"> <h3 className="text-muted-foreground mb-1 text-xs font-normal">
Task Summary Task Summary
</h3> </h3>
<p className="text-xs font-normal text-gray-800">{summary}</p> <p className="text-foreground/80 text-xs font-normal">{summary}</p>
</div> </div>
)} )}
{summaryText && status === "done" && ( {summaryText && status === "done" && (
<div className="border-t border-green-100 bg-green-50 p-2"> <div
className={`border-t p-2 ${
completed === false
? "border-amber-300 bg-amber-100/50 dark:border-amber-800 dark:bg-amber-900/50"
: "border-green-300 bg-green-100/50 dark:border-green-800 dark:bg-green-900/50"
}`}
>
<button <button
onClick={() => setShowSummary(!showSummary)} onClick={() => setShowSummary(!showSummary)}
className="flex items-center gap-1 text-xs font-normal text-green-700 hover:text-green-800" className={`flex cursor-pointer items-center gap-1 text-xs font-normal ${
completed === false
? "text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300"
: "text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
}`}
> >
<FileText className="h-3 w-3" /> <FileText className="h-3 w-3" />
{showSummary ? "Hide summary" : "Show summary"} {showSummary ? "Hide summary" : "Show summary"}
</button> </button>
{showSummary && ( {showSummary && (
<p className="mt-1 text-xs font-normal text-green-800"> <p
className={`mt-1 text-xs font-normal ${
completed === false
? "text-amber-700 dark:text-amber-300"
: "text-green-700 dark:text-green-300"
}`}
>
{summaryText} {summaryText}
</p> </p>
)} )}

View file

@ -19,10 +19,12 @@ import { Fragment } from "react/jsx-runtime";
import { useQueryState, parseAsBoolean } from "nuqs"; import { useQueryState, parseAsBoolean } from "nuqs";
import { Interrupt } from "./interrupt"; import { Interrupt } from "./interrupt";
import { ActionStep, ActionItemProps } from "@/components/gen-ui/action-step"; import { ActionStep, ActionItemProps } from "@/components/gen-ui/action-step";
import { TaskSummary } from "@/components/gen-ui/task-summary";
import { ToolCall } from "@langchain/core/messages/tool"; import { ToolCall } from "@langchain/core/messages/tool";
import { import {
createApplyPatchToolFields, createApplyPatchToolFields,
createShellToolFields, createShellToolFields,
createSetTaskStatusToolFields,
} from "@open-swe/shared/open-swe/tools"; } from "@open-swe/shared/open-swe/tools";
import { z } from "zod"; import { z } from "zod";
import { isAIMessageSDK, isToolMessageSDK } from "@/lib/langchain-messages"; import { isAIMessageSDK, isToolMessageSDK } from "@/lib/langchain-messages";
@ -34,6 +36,8 @@ const shellTool = createShellToolFields(dummyRepo);
type ShellToolArgs = z.infer<typeof shellTool.schema>; type ShellToolArgs = z.infer<typeof shellTool.schema>;
const applyPatchTool = createApplyPatchToolFields(dummyRepo); const applyPatchTool = createApplyPatchToolFields(dummyRepo);
type ApplyPatchToolArgs = z.infer<typeof applyPatchTool.schema>; type ApplyPatchToolArgs = z.infer<typeof applyPatchTool.schema>;
const setTaskStatusTool = createSetTaskStatusToolFields();
type SetTaskStatusToolArgs = z.infer<typeof setTaskStatusTool.schema>;
function CustomComponent({ function CustomComponent({
message, message,
@ -193,6 +197,32 @@ export function AssistantMessage({
) )
: []; : [];
const taskStatusToolCall = message
? aiToolCalls.find((tc) => tc.name === setTaskStatusTool.name)
: undefined;
// We can be sure that if the task status tool call is present, it will be the
// only tool call/result we need to render for this message.
if (taskStatusToolCall) {
const args = taskStatusToolCall.args as SetTaskStatusToolArgs;
const correspondingToolResult = toolResults.find(
(tr) => tr && tr.tool_call_id === taskStatusToolCall.id,
);
const status = correspondingToolResult ? "done" : "generating";
const completed = args.task_status === "completed";
return (
<div className="flex flex-col gap-4">
<TaskSummary
status={status}
completed={completed}
summaryText={args.reasoning}
/>
</div>
);
}
if (shellOrPatchToolCalls.length > 0) { if (shellOrPatchToolCalls.length > 0) {
const actionItems = shellOrPatchToolCalls.map((toolCall) => { const actionItems = shellOrPatchToolCalls.map((toolCall) => {
const correspondingToolResult = toolResults.find( const correspondingToolResult = toolResults.find(

View file

@ -100,3 +100,27 @@ export function createUpdatePlanToolFields() {
"\nThe reasoning you pass to this tool will be used in the step that actually updates the plan, so ensure it is useful and concise.", "\nThe reasoning you pass to this tool will be used in the step that actually updates the plan, so ensure it is useful and concise.",
}; };
} }
export function createSetTaskStatusToolFields() {
const setTaskStatusToolSchema = z.object({
reasoning: z
.string()
.describe(
"A concise reasoning summary for the status of the current task, explaining why you think it is completed or not completed.",
),
task_status: z
.enum(["completed", "not_completed"])
.describe(
"The status of the current task, based on the reasoning provided.",
),
});
const setTaskStatusTool = {
name: "set_task_status",
description:
"The status of the current task, along with a concise reasoning summary to support the status.",
schema: setTaskStatusToolSchema,
};
return setTaskStatusTool;
}