Roo-Code/webview-ui/src/components/chat/UpdateTodoListToolBlock.tsx
axb 7645aad435
add todo tool (#5182)
Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>
2025-07-07 14:07:39 -04:00

498 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef } from "react"
import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock"
import MarkdownBlock from "../common/MarkdownBlock"
interface TodoItem {
id?: string
content: string
status?: "completed" | "in_progress" | string
}
/**
* @description
* Editable Todo List component. Each time the todo list changes (edit, add, delete, status switch), the parent component will be notified via the onChange callback.
* The parent component should synchronize the latest todos to the model in onChange.
*/
interface UpdateTodoListToolBlockProps {
todos?: TodoItem[]
content?: string
/**
* Callback when todos change, be sure to implement and notify the model with the latest todos
* @param todos Latest todo list
*/
onChange: (todos: TodoItem[]) => void
/** Whether editing is allowed (controlled externally) */
editable?: boolean
userEdited?: boolean
}
const STATUS_OPTIONS = [
{ value: "", label: "Not Started", color: "var(--vscode-foreground)", border: "#bbb", bg: "transparent" },
{
value: "in_progress",
label: "In Progress",
color: "var(--vscode-charts-yellow)",
border: "var(--vscode-charts-yellow)",
bg: "rgba(255, 221, 51, 0.15)",
},
{
value: "completed",
label: "Completed",
color: "var(--vscode-charts-green)",
border: "var(--vscode-charts-green)",
bg: "var(--vscode-charts-green)",
},
]
const genId = () => Math.random().toString(36).slice(2, 10)
const UpdateTodoListToolBlock: React.FC<UpdateTodoListToolBlockProps> = ({
todos = [],
content,
onChange,
editable = true,
userEdited = false,
}) => {
const [editTodos, setEditTodos] = useState<TodoItem[]>(
todos.length > 0 ? todos.map((todo) => ({ ...todo, id: todo.id || genId() })) : [],
)
const [adding, setAdding] = useState(false)
const [newContent, setNewContent] = useState("")
const newInputRef = useRef<HTMLInputElement>(null)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [isEditing, setIsEditing] = useState(false)
// Automatically exit edit mode when external editable becomes false
useEffect(() => {
if (!editable && isEditing) {
setIsEditing(false)
}
}, [editable, isEditing])
// Check if onChange is passed
useEffect(() => {
if (typeof onChange !== "function") {
console.warn(
"UpdateTodoListToolBlock: onChange callback not passed, cannot notify model after todo changes!",
)
}
// Only check once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Sync when external props.todos changes
useEffect(() => {
setEditTodos(todos.length > 0 ? todos.map((todo) => ({ ...todo, id: todo.id || genId() })) : [])
}, [todos])
// Auto focus on new item
useEffect(() => {
if (adding && newInputRef.current) {
newInputRef.current.focus()
}
}, [adding])
// Edit content
const handleContentChange = (id: string, value: string) => {
const newTodos = editTodos.map((todo) => (todo.id === id ? { ...todo, content: value } : todo))
setEditTodos(newTodos)
onChange?.(newTodos)
}
// Change status
const handleStatusChange = (id: string, status: string) => {
const newTodos = editTodos.map((todo) => (todo.id === id ? { ...todo, status } : todo))
setEditTodos(newTodos)
onChange?.(newTodos)
}
// Delete (confirmation dialog)
const handleDelete = (id: string) => {
setDeleteId(id)
}
const confirmDelete = () => {
if (!deleteId) return
const newTodos = editTodos.filter((todo) => todo.id !== deleteId)
setEditTodos(newTodos)
onChange?.(newTodos)
setDeleteId(null)
}
const cancelDelete = () => setDeleteId(null)
// Add
const handleAdd = () => {
if (!newContent.trim()) return
const newTodo: TodoItem = {
id: genId(),
content: newContent.trim(),
status: "",
}
const newTodos = [...editTodos, newTodo]
setEditTodos(newTodos)
onChange?.(newTodos)
setNewContent("")
setAdding(false)
}
// Add on Enter
const handleNewInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleAdd()
} else if (e.key === "Escape") {
setAdding(false)
setNewContent("")
}
}
if (userEdited) {
return (
<ToolUseBlock>
<ToolUseBlockHeader>
<div className="flex items-center w-full" style={{ width: "100%" }}>
<span
className="codicon codicon-feedback mr-1.5"
style={{ color: "var(--vscode-charts-yellow)" }}
/>
<span className="font-bold mr-2" style={{ fontWeight: "bold" }}>
User Edit
</span>
<div className="flex-grow" />
</div>
</ToolUseBlockHeader>
<div className="overflow-x-auto max-w-full" style={{ padding: "12px 0 8px 0" }}>
<span className="text-vscode-descriptionForeground">User Edits</span>
</div>
</ToolUseBlock>
)
}
return (
<>
<ToolUseBlock>
<ToolUseBlockHeader>
<div className="flex items-center w-full" style={{ width: "100%" }}>
<span
className="codicon codicon-checklist mr-1.5"
style={{ color: "var(--vscode-foreground)" }}
/>
<span className="font-bold mr-2" style={{ fontWeight: "bold" }}>
Todo List Updated
</span>
<div className="flex-grow" />
{editable && (
<button
onClick={() => setIsEditing(!isEditing)}
style={{
border: isEditing
? "1px solid var(--vscode-button-border)"
: "1px solid var(--vscode-button-secondaryBorder)",
background: isEditing
? "var(--vscode-button-background)"
: "var(--vscode-button-secondaryBackground)",
color: isEditing
? "var(--vscode-button-foreground)"
: "var(--vscode-button-secondaryForeground)",
borderRadius: 4,
padding: "2px 8px",
cursor: "pointer",
fontSize: 13,
marginLeft: 8,
}}>
{isEditing ? "Done" : "Edit"}
</button>
)}
</div>
</ToolUseBlockHeader>
<div className="overflow-x-auto max-w-full" style={{ padding: "6px 0 2px 0" }}>
{Array.isArray(editTodos) && editTodos.length > 0 ? (
<ul style={{ margin: 0, paddingLeft: 0, listStyle: "none" }}>
{editTodos.map((todo, idx) => {
let icon
if (todo.status === "completed") {
icon = (
<span
style={{
display: "inline-block",
width: 8,
height: 8,
borderRadius: "50%",
background: "var(--vscode-charts-green)",
marginRight: 6,
marginTop: 7,
flexShrink: 0,
}}
/>
)
} else if (todo.status === "in_progress") {
icon = (
<span
style={{
display: "inline-block",
width: 8,
height: 8,
borderRadius: "50%",
background: "var(--vscode-charts-yellow)",
marginRight: 6,
marginTop: 7,
flexShrink: 0,
}}
/>
)
} else {
icon = (
<span
style={{
display: "inline-block",
width: 8,
height: 8,
borderRadius: "50%",
border: "1px solid var(--vscode-descriptionForeground)",
background: "transparent",
marginRight: 6,
marginTop: 7,
flexShrink: 0,
}}
/>
)
}
return (
<li
key={todo.id || idx}
style={{
marginBottom: 2,
display: "flex",
alignItems: "flex-start",
minHeight: 20,
}}>
{icon}
{isEditing ? (
<input
type="text"
value={todo.content}
placeholder="Enter todo item"
onChange={(e) => handleContentChange(todo.id!, e.target.value)}
style={{
flex: 1,
minWidth: 0,
fontWeight: 500,
color: "var(--vscode-input-foreground)",
background: "var(--vscode-input-background)",
border: "none",
outline: "none",
fontSize: 13,
marginRight: 6,
padding: "1px 3px",
borderBottom: "1px solid var(--vscode-input-border)",
}}
onBlur={(e) => {
if (!e.target.value.trim()) {
handleDelete(todo.id!)
}
}}
/>
) : (
<span
style={{
flex: 1,
minWidth: 0,
fontWeight: 500,
color:
todo.status === "completed"
? "var(--vscode-charts-green)"
: todo.status === "in_progress"
? "var(--vscode-charts-yellow)"
: "var(--vscode-foreground)",
fontSize: 13,
marginRight: 6,
padding: "1px 3px",
lineHeight: "1.4",
}}>
{todo.content}
</span>
)}
{isEditing && (
<select
value={todo.status || ""}
onChange={(e) => handleStatusChange(todo.id!, e.target.value)}
style={{
marginRight: 6,
borderRadius: 4,
border: "1px solid var(--vscode-input-border)",
background: "var(--vscode-input-background)",
color: "var(--vscode-input-foreground)",
fontSize: 12,
padding: "1px 4px",
}}>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)}
{isEditing && (
<button
onClick={() => handleDelete(todo.id!)}
style={{
border: "none",
background: "transparent",
color: "#f14c4c",
cursor: "pointer",
fontSize: 14,
marginLeft: 2,
padding: 0,
lineHeight: 1,
}}
title="Remove">
×
</button>
)}
</li>
)
})}
{adding ? (
<li style={{ marginTop: 2, display: "flex", alignItems: "center" }}>
<span style={{ width: 14, marginRight: 6 }} />
<input
ref={newInputRef}
type="text"
value={newContent}
placeholder="Enter todo item, press Enter to add"
onChange={(e) => setNewContent(e.target.value)}
onKeyDown={handleNewInputKeyDown}
style={{
flex: 1,
minWidth: 0,
fontWeight: 500,
color: "var(--vscode-foreground)",
background: "transparent",
border: "none",
outline: "none",
fontSize: 13,
marginRight: 6,
padding: "1px 3px",
borderBottom: "1px solid #eee",
}}
/>
<button
onClick={handleAdd}
disabled={!newContent.trim()}
style={{
border: "1px solid var(--vscode-button-border)",
background: "var(--vscode-button-background)",
color: "var(--vscode-button-foreground)",
borderRadius: 4,
padding: "1px 7px",
cursor: newContent.trim() ? "pointer" : "not-allowed",
fontSize: 12,
marginRight: 4,
}}>
Add
</button>
<button
onClick={() => {
setAdding(false)
setNewContent("")
}}
style={{
border: "1px solid var(--vscode-button-secondaryBorder)",
background: "var(--vscode-button-secondaryBackground)",
color: "var(--vscode-button-secondaryForeground)",
borderRadius: 4,
padding: "1px 7px",
cursor: "pointer",
fontSize: 12,
}}>
Cancel
</button>
</li>
) : (
<li style={{ marginTop: 2 }}>
{isEditing && (
<button
onClick={() => setAdding(true)}
style={{
border: "1px dashed var(--vscode-button-secondaryBorder)",
background: "var(--vscode-button-secondaryBackground)",
color: "var(--vscode-button-secondaryForeground)",
borderRadius: 4,
padding: "1px 8px",
cursor: "pointer",
fontSize: 12,
}}>
+ Add Todo
</button>
)}
</li>
)}
</ul>
) : (
<MarkdownBlock markdown={content} />
)}
</div>
{/* Delete confirmation dialog */}
{deleteId && (
<div
style={{
position: "fixed",
left: 0,
top: 0,
right: 0,
bottom: 0,
background: "rgba(0,0,0,0.15)",
zIndex: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={cancelDelete}>
<div
style={{
background: "#fff",
borderRadius: 8,
boxShadow: "0 2px 16px rgba(0,0,0,0.15)",
padding: "16px 20px",
minWidth: 200,
zIndex: 10000,
}}
onClick={(e) => e.stopPropagation()}>
<div style={{ marginBottom: 12, fontSize: 14, color: "#333" }}>
Are you sure you want to delete this todo item?
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<button
onClick={cancelDelete}
style={{
border: "1px solid #bbb",
background: "transparent",
color: "#888",
borderRadius: 4,
padding: "2px 10px",
cursor: "pointer",
fontSize: 12,
}}>
Cancel
</button>
<button
onClick={confirmDelete}
style={{
border: "1px solid #f14c4c",
background: "#f14c4c",
color: "#fff",
borderRadius: 4,
padding: "2px 10px",
cursor: "pointer",
fontSize: 12,
}}>
Delete
</button>
</div>
</div>
</div>
)}
</ToolUseBlock>
</>
)
}
export default UpdateTodoListToolBlock