From b6d8d0d9bae08fba837110ffa950f8f5cc5693b7 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 16 May 2025 17:33:29 -0700 Subject: [PATCH 01/11] feat: implement hierarchical task history with parent-child relationships This change adds support for tracking parent-child relationships between tasks in the task history, particularly for tasks created using the new_task tool. Key changes: - Add parent_task_id field to HistoryItem schema - Modify Task class to store parent task ID - Update taskMetadata to include parent task ID in history items - Enhance History UI components to display hierarchical relationships - Add visual indicators for tasks with children - Add green styling for completed tasks Fixes: #3688 Signed-off-by: Eric Wheeler --- src/core/task-persistence/taskMetadata.ts | 3 + src/core/task/Task.ts | 5 + src/exports/roo-code.d.ts | 3 + src/exports/types.ts | 3 + src/schemas/index.ts | 1 + .../src/components/history/HistoryPreview.tsx | 104 +-- .../src/components/history/HistoryView.tsx | 593 +++++++++++------- .../src/components/history/useTaskSearch.ts | 69 +- 8 files changed, 482 insertions(+), 299 deletions(-) diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index 9784e622958..2c144259fe5 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -17,6 +17,7 @@ export type TaskMetadataOptions = { taskNumber: number globalStoragePath: string workspace: string + parentTaskId?: string } export async function taskMetadata({ @@ -25,6 +26,7 @@ export async function taskMetadata({ taskNumber, globalStoragePath, workspace, + parentTaskId, }: TaskMetadataOptions) { const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) const taskMessage = messages[0] // First message is always the task say. @@ -57,6 +59,7 @@ export async function taskMetadata({ totalCost: tokenUsage.totalCost, size: taskDirSize, workspace, + parent_task_id: parentTaskId, } return { historyItem, tokenUsage } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 918041e459c..78ddad8813b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -117,6 +117,7 @@ export class Task extends EventEmitter { readonly rootTask: Task | undefined = undefined readonly parentTask: Task | undefined = undefined + readonly parentTaskId?: string readonly taskNumber: number readonly workspacePath: string @@ -237,6 +238,9 @@ export class Task extends EventEmitter { this.rootTask = rootTask this.parentTask = parentTask + if (parentTask) { + this.parentTaskId = parentTask.taskId + } this.taskNumber = taskNumber if (historyItem) { @@ -344,6 +348,7 @@ export class Task extends EventEmitter { taskNumber: this.taskNumber, globalStoragePath: this.globalStoragePath, workspace: this.cwd, + parentTaskId: this.parentTaskId, }) this.emit("taskTokenUsageUpdated", this.taskId, tokenUsage) diff --git a/src/exports/roo-code.d.ts b/src/exports/roo-code.d.ts index 2961f17489f..206e41e8c94 100644 --- a/src/exports/roo-code.d.ts +++ b/src/exports/roo-code.d.ts @@ -54,6 +54,7 @@ type GlobalSettings = { totalCost: number size?: number | undefined workspace?: string | undefined + parent_task_id?: string | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined @@ -767,6 +768,7 @@ type IpcMessage = totalCost: number size?: number | undefined workspace?: string | undefined + parent_task_id?: string | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined @@ -1230,6 +1232,7 @@ type TaskCommand = totalCost: number size?: number | undefined workspace?: string | undefined + parent_task_id?: string | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined diff --git a/src/exports/types.ts b/src/exports/types.ts index 47cc16a7499..f186318f6cf 100644 --- a/src/exports/types.ts +++ b/src/exports/types.ts @@ -54,6 +54,7 @@ type GlobalSettings = { totalCost: number size?: number | undefined workspace?: string | undefined + parent_task_id?: string | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined @@ -781,6 +782,7 @@ type IpcMessage = totalCost: number size?: number | undefined workspace?: string | undefined + parent_task_id?: string | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined @@ -1246,6 +1248,7 @@ type TaskCommand = totalCost: number size?: number | undefined workspace?: string | undefined + parent_task_id?: string | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 209bc67d2c5..ac377e1df47 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -150,6 +150,7 @@ export const historyItemSchema = z.object({ totalCost: z.number(), size: z.number().optional(), workspace: z.string().optional(), + parent_task_id: z.string().optional(), }) export type HistoryItem = z.infer diff --git a/webview-ui/src/components/history/HistoryPreview.tsx b/webview-ui/src/components/history/HistoryPreview.tsx index 080cbff3e20..5e992a320cd 100644 --- a/webview-ui/src/components/history/HistoryPreview.tsx +++ b/webview-ui/src/components/history/HistoryPreview.tsx @@ -2,62 +2,84 @@ import { memo } from "react" import { vscode } from "@/utils/vscode" import { formatLargeNumber, formatDate } from "@/utils/format" +import { cn } from "@/lib/utils" // Added for cn utility +import { useExtensionState } from "@/context/ExtensionStateContext" // Added for completion status +import { ClineMessage } from "@roo/shared/ExtensionMessage" // Added for ClineMessage type import { CopyButton } from "./CopyButton" -import { useTaskSearch } from "./useTaskSearch" +import { useTaskSearch, HierarchicalHistoryItem } from "./useTaskSearch" // Updated import -import { Coins } from "lucide-react" +import { Coins, ChevronRight } from "lucide-react" // Added ChevronRight for children indicator const HistoryPreview = () => { const { tasks, showAllWorkspaces } = useTaskSearch() + const { clineMessages, currentTaskItem } = useExtensionState() return ( <>
{tasks.length !== 0 && ( <> - {tasks.slice(0, 3).map((item) => ( -
vscode.postMessage({ type: "showTaskWithId", text: item.id })}> -
-
- - {formatDate(item.ts)} - - -
-
- {item.task} -
-
- ↑ {formatLargeNumber(item.tokensIn || 0)} - ↓ {formatLargeNumber(item.tokensOut || 0)} - {!!item.totalCost && ( - - {" "} - {"$" + item.totalCost?.toFixed(2)} - + {tasks.slice(0, 3).map((item: HierarchicalHistoryItem) => { + let isCompleted = false + if (item.id === currentTaskItem?.id && clineMessages) { + isCompleted = clineMessages.some( + (msg: ClineMessage) => + (msg.type === "ask" && msg.ask === "completion_result") || + (msg.type === "say" && msg.say === "completion_result"), + ) + } + return ( +
vscode.postMessage({ type: "showTaskWithId", text: item.id })}> +
+
+
+ {item.children && item.children.length > 0 && ( + + )} + + {formatDate(item.ts)} + +
+ +
+
+
+ ↑ {formatLargeNumber(item.tokensIn || 0)} + ↓ {formatLargeNumber(item.tokensOut || 0)} + {!!item.totalCost && ( + + {" "} + {"$" + item.totalCost?.toFixed(2)} + + )} +
+ {showAllWorkspaces && item.workspace && ( +
+ + {item.workspace} +
)}
- {showAllWorkspaces && item.workspace && ( -
- - {item.workspace} -
- )}
-
- ))} + ) + })} )}
diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index bcb86a8d130..d188f6d5f8c 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -3,17 +3,19 @@ import { DeleteTaskDialog } from "./DeleteTaskDialog" import { BatchDeleteTaskDialog } from "./BatchDeleteTaskDialog" import prettyBytes from "pretty-bytes" import { Virtuoso } from "react-virtuoso" - import { VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react" import { vscode } from "@/utils/vscode" import { formatLargeNumber, formatDate } from "@/utils/format" import { cn } from "@/lib/utils" import { Button, Checkbox } from "@/components/ui" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" import { useAppTranslation } from "@/i18n/TranslationContext" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { ClineMessage } from "@roo/shared/ExtensionMessage" // Added for explicit type import { Tab, TabContent, TabHeader } from "../common/Tab" -import { useTaskSearch } from "./useTaskSearch" +import { useTaskSearch, HierarchicalHistoryItem } from "./useTaskSearch" import { ExportButton } from "./ExportButton" import { CopyButton } from "./CopyButton" @@ -23,6 +25,332 @@ type HistoryViewProps = { type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant" +// Define TaskDisplayItem component +interface TaskDisplayItemProps { + item: HierarchicalHistoryItem + level: number + isSelectionMode: boolean + selectedTaskIds: string[] + toggleTaskSelection: (taskId: string, isSelected: boolean) => void + onTaskClick: (taskId: string) => void + setDeleteTaskId: (taskId: string | null) => void + showAllWorkspaces: boolean + t: (key: string, options?: any) => string + currentTaskMessages: ClineMessage[] | undefined + currentTaskId: string | undefined +} + +const TaskDisplayItem: React.FC = memo( + ({ + item, + level, + isSelectionMode, + selectedTaskIds, + toggleTaskSelection, + onTaskClick, + setDeleteTaskId, + showAllWorkspaces, + t, + currentTaskMessages, + currentTaskId, + }) => { + const [isOpen, setIsOpen] = useState(false) + let isCompleted = false + // Completion status is only checked if the item is the currently active task + if (item.id === currentTaskId && currentTaskMessages) { + isCompleted = currentTaskMessages.some( + ( + msg: ClineMessage, // Explicitly type msg + ) => + (msg.type === "ask" && msg.ask === "completion_result") || + (msg.type === "say" && msg.say === "completion_result"), + ) + } + + const content = ( +
+ {isSelectionMode && ( +
{ + e.stopPropagation() + }}> + toggleTaskSelection(item.id, checked === true)} + variant="description" + /> +
+ )} +
+
+
+ {item.children && item.children.length > 0 && ( + { + e.stopPropagation() // Prevent task click when toggling collapse + setIsOpen(!isOpen) + }} + /> + )} + + {formatDate(item.ts)} + +
+
+ {!isSelectionMode && ( + + )} +
+
+
+
+
+
+ + {t("history:tokensLabel")} + + + + {formatLargeNumber(item.tokensIn || 0)} + + + + {formatLargeNumber(item.tokensOut || 0)} + +
+ {!item.totalCost && !isSelectionMode && ( +
+ + +
+ )} +
+ + {!!item.cacheWrites && ( +
+ + {t("history:cacheLabel")} + + + + +{formatLargeNumber(item.cacheWrites || 0)} + + + + {formatLargeNumber(item.cacheReads || 0)} + +
+ )} + + {!!item.totalCost && ( +
+
+ + {t("history:apiCostLabel")} + + + ${item.totalCost?.toFixed(4)} + +
+ {!isSelectionMode && ( +
+ + +
+ )} +
+ )} + + {showAllWorkspaces && item.workspace && ( +
+ + {item.workspace} +
+ )} +
+
+
+ ) + + if (item.children && item.children.length > 0) { + return ( + + { + if (!isSelectionMode) onTaskClick(item.id) + }}> +
{content}
+
+ + {item.children.map((child) => ( + + ))} + +
+ ) + } + + return ( +
{ + if (isSelectionMode) { + toggleTaskSelection(item.id, !selectedTaskIds.includes(item.id)) + } else { + onTaskClick(item.id) + } + }}> + {content} +
+ ) + }, +) + const HistoryView = ({ onDone }: HistoryViewProps) => { const { tasks, @@ -35,6 +363,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { setShowAllWorkspaces, } = useTaskSearch() const { t } = useAppTranslation() + // Destructure clineMessages and currentTaskItem (which contains the active task's ID) + const { clineMessages, currentTaskItem } = useExtensionState() const [deleteTaskId, setDeleteTaskId] = useState(null) const [isSelectionMode, setIsSelectionMode] = useState(false) @@ -99,7 +429,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
{ flexGrow: 1, overflowY: "scroll", }} - data={tasks} + data={tasks as HierarchicalHistoryItem[]} data-testid="virtuoso-container" initialTopMostItemIndex={0} components={{ List: React.forwardRef((props, ref) => ( -
+
} data-testid="virtuoso-item-list" /> )), }} - itemContent={(index, item) => ( + itemContent={(index, item: HierarchicalHistoryItem) => (
{ - if (isSelectionMode) { - toggleTaskSelection(item.id, !selectedTaskIds.includes(item.id)) - } else { - vscode.postMessage({ type: "showTaskWithId", text: item.id }) - } - }}> -
- {/* Show checkbox in selection mode */} - {isSelectionMode && ( -
{ - e.stopPropagation() - }}> - - toggleTaskSelection(item.id, checked === true) - } - variant="description" - /> -
- )} - -
-
- - {formatDate(item.ts)} - -
- {!isSelectionMode && ( - - )} -
-
-
-
-
-
- - {t("history:tokensLabel")} - - - - {formatLargeNumber(item.tokensIn || 0)} - - - - {formatLargeNumber(item.tokensOut || 0)} - -
- {!item.totalCost && !isSelectionMode && ( -
- - -
- )} -
- - {!!item.cacheWrites && ( -
- - {t("history:cacheLabel")} - - - - +{formatLargeNumber(item.cacheWrites || 0)} - - - - {formatLargeNumber(item.cacheReads || 0)} - -
- )} - - {!!item.totalCost && ( -
-
- - {t("history:apiCostLabel")} - - - ${item.totalCost?.toFixed(4)} - -
- {!isSelectionMode && ( -
- - -
- )} -
- )} - - {showAllWorkspaces && item.workspace && ( -
- - {item.workspace} -
- )} -
-
-
+ })}> + vscode.postMessage({ type: "showTaskWithId", text: taskId })} + setDeleteTaskId={setDeleteTaskId} + showAllWorkspaces={showAllWorkspaces} + t={t} + currentTaskMessages={clineMessages} // Pass active task's messages + currentTaskId={currentTaskItem?.id} // Pass active task's ID from currentTaskItem + />
)} /> diff --git a/webview-ui/src/components/history/useTaskSearch.ts b/webview-ui/src/components/history/useTaskSearch.ts index 47d5c3719c0..e288c50895d 100644 --- a/webview-ui/src/components/history/useTaskSearch.ts +++ b/webview-ui/src/components/history/useTaskSearch.ts @@ -3,9 +3,14 @@ import { Fzf } from "fzf" import { highlightFzfMatch } from "@/utils/highlight" import { useExtensionState } from "@/context/ExtensionStateContext" +import { HistoryItem } from "../../../../src/shared/HistoryItem" type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant" +export interface HierarchicalHistoryItem extends HistoryItem { + children?: HierarchicalHistoryItem[] +} + export const useTaskSearch = () => { const { taskHistory, cwd } = useExtensionState() const [searchQuery, setSearchQuery] = useState("") @@ -38,7 +43,7 @@ export const useTaskSearch = () => { }, [presentableTasks]) const tasks = useMemo(() => { - let results = presentableTasks + let results: HierarchicalHistoryItem[] = [...presentableTasks] if (searchQuery) { const searchResults = fzf.find(searchQuery) @@ -57,25 +62,53 @@ export const useTaskSearch = () => { }) } - // Then sort the results - return [...results].sort((a, b) => { - switch (sortOption) { - case "oldest": - return (a.ts || 0) - (b.ts || 0) - case "mostExpensive": - return (b.totalCost || 0) - (a.totalCost || 0) - case "mostTokens": - const aTokens = (a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0) - const bTokens = (b.tokensIn || 0) + (b.tokensOut || 0) + (b.cacheWrites || 0) + (b.cacheReads || 0) - return bTokens - aTokens - case "mostRelevant": - // Keep fuse order if searching, otherwise sort by newest - return searchQuery ? 0 : (b.ts || 0) - (a.ts || 0) - case "newest": - default: - return (b.ts || 0) - (a.ts || 0) + // Build hierarchy + const taskMap = new Map() + results.forEach((task) => taskMap.set(task.id, { ...task, children: [] })) + + const rootTasks: HierarchicalHistoryItem[] = [] + results.forEach((task) => { + if (task.parent_task_id && taskMap.has(task.parent_task_id)) { + const parent = taskMap.get(task.parent_task_id) + if (parent) { + parent.children = parent.children || [] + parent.children.push(taskMap.get(task.id)!) + } + } else { + rootTasks.push(taskMap.get(task.id)!) } }) + + // Sort children within each parent and root tasks + const sortTasksRecursive = (tasksToSort: HierarchicalHistoryItem[]): HierarchicalHistoryItem[] => { + tasksToSort.sort((a, b) => { + switch (sortOption) { + case "oldest": + return (a.ts || 0) - (b.ts || 0) + case "mostExpensive": + return (b.totalCost || 0) - (a.totalCost || 0) + case "mostTokens": + const aTokens = + (a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0) + const bTokens = + (b.tokensIn || 0) + (b.tokensOut || 0) + (b.cacheWrites || 0) + (b.cacheReads || 0) + return bTokens - aTokens + case "mostRelevant": + return searchQuery ? 0 : (b.ts || 0) - (a.ts || 0) // FZF order for root, timestamp for children + case "newest": + default: + return (b.ts || 0) - (a.ts || 0) + } + }) + tasksToSort.forEach((task) => { + if (task.children && task.children.length > 0) { + task.children = sortTasksRecursive(task.children) + } + }) + return tasksToSort + } + + return sortTasksRecursive(rootTasks) }, [presentableTasks, searchQuery, fzf, sortOption]) return { From 480aa1ebe19622f0b8f60fe00b735ade9ed34a5a Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 16 May 2025 19:37:29 -0700 Subject: [PATCH 02/11] feat: Implement hierarchical task history with completion status Implement hierarchical display for tasks in the history view, allowing parent tasks to be expanded to show child tasks. - Add `parent_task_id` to `HistoryItem` schema to establish parent-child relationships. - Update frontend to render tasks hierarchically, with child tasks indented and collapsible under their parents. Introduce a `completed` status for tasks: - Add `completed` boolean flag to `HistoryItem` schema. - Backend logic in `Task.ts` now sets `completed: true` when an `attempt_completion` is processed (e.g., `ask: "completion_result"` or `say: "completion_result"` messages) and `completed: false` if new messages are added to a completed task. - Frontend history view now displays completed tasks with a distinct text color (`var(--vscode-testing-iconPassed)`). Enhance UI for child tasks in history: - Child task entries are now more compact, with reduced padding. - Token and cost information is omitted for child tasks to save space. Signed-off-by: Eric Wheeler --- .../config/__tests__/ContextProxy.test.ts | 2 + src/core/task-persistence/taskMetadata.ts | 12 ++ src/core/task/Task.ts | 62 +++++++- src/exports/roo-code.d.ts | 3 + src/exports/types.ts | 3 + src/schemas/index.ts | 1 + .../src/components/history/HistoryPreview.tsx | 26 ++-- .../src/components/history/HistoryView.tsx | 134 +++++++++--------- 8 files changed, 151 insertions(+), 92 deletions(-) diff --git a/src/core/config/__tests__/ContextProxy.test.ts b/src/core/config/__tests__/ContextProxy.test.ts index bdd3d5ddc56..08ab5f71d90 100644 --- a/src/core/config/__tests__/ContextProxy.test.ts +++ b/src/core/config/__tests__/ContextProxy.test.ts @@ -128,6 +128,7 @@ describe("ContextProxy", () => { tokensIn: 1, tokensOut: 1, totalCost: 1, + completed: false, }, ] @@ -160,6 +161,7 @@ describe("ContextProxy", () => { tokensIn: 1, tokensOut: 1, totalCost: 1, + completed: false, }, ] diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index 2c144259fe5..27458a04535 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -18,6 +18,8 @@ export type TaskMetadataOptions = { globalStoragePath: string workspace: string parentTaskId?: string + setCompleted?: boolean + unsetCompleted?: boolean } export async function taskMetadata({ @@ -27,6 +29,8 @@ export async function taskMetadata({ globalStoragePath, workspace, parentTaskId, + setCompleted, + unsetCompleted, }: TaskMetadataOptions) { const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) const taskMessage = messages[0] // First message is always the task say. @@ -47,6 +51,13 @@ export async function taskMetadata({ const tokenUsage = getApiMetrics(combineApiRequests(combineCommandSequences(messages.slice(1)))) + let completedValue = false // Default to schema's default + if (setCompleted === true) { + completedValue = true + } else if (unsetCompleted === true) { + completedValue = false + } + const historyItem: HistoryItem = { id: taskId, number: taskNumber, @@ -60,6 +71,7 @@ export async function taskMetadata({ size: taskDirSize, workspace, parent_task_id: parentTaskId, + completed: completedValue, } return { historyItem, tokenUsage } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 78ddad8813b..30e54b2def6 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -124,6 +124,7 @@ export class Task extends EventEmitter { providerRef: WeakRef private readonly globalStoragePath: string abort: boolean = false + private isCompleted: boolean = false didFinishAbortingStream = false abandoned = false isInitialized = false @@ -209,6 +210,7 @@ export class Task extends EventEmitter { } this.taskId = historyItem ? historyItem.id : crypto.randomUUID() + this.isCompleted = historyItem?.completed ?? false // normal use-case is usually retry similar history task with new workspace this.workspacePath = parentTask ? parentTask.workspacePath @@ -342,14 +344,22 @@ export class Task extends EventEmitter { globalStoragePath: this.globalStoragePath, }) - const { historyItem, tokenUsage } = await taskMetadata({ + const metadataOptions: Parameters[0] = { messages: this.clineMessages, taskId: this.taskId, taskNumber: this.taskNumber, globalStoragePath: this.globalStoragePath, workspace: this.cwd, parentTaskId: this.parentTaskId, - }) + } + + if (this.isCompleted) { + metadataOptions.setCompleted = true + } else { + metadataOptions.unsetCompleted = true + } + + const { historyItem, tokenUsage } = await taskMetadata(metadataOptions) this.emit("taskTokenUsageUpdated", this.taskId, tokenUsage) @@ -454,6 +464,12 @@ export class Task extends EventEmitter { await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text }) } + // If the AI is asking for a completion_result, it means it has attempted completion. + // Mark as completed now. It will be persisted by saveClineMessages called within addToClineMessages or by the save below. + if (type === "completion_result") { + this.isCompleted = true + } + await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) if (this.lastMessageTs !== askTs) { @@ -464,6 +480,16 @@ export class Task extends EventEmitter { } const result = { response: this.askResponse!, text: this.askResponseText, images: this.askResponseImages } + + // If the task was marked as completed due to a "completion_result" ask, + // but the user did not confirm with "yesButtonClicked" (e.g., they clicked "No" or provided new input), + // then the task is no longer considered completed. + if (type === "completion_result" && result.response !== "yesButtonClicked") { + this.isCompleted = false + // This change will be persisted by the next call to saveClineMessages, + // for example, when user feedback is added as a new message. + } + this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined @@ -472,6 +498,13 @@ export class Task extends EventEmitter { } async handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { + const lastAskMessage = this.clineMessages + .slice() + .reverse() + .find((m) => m.type === "ask") + if (this.isCompleted && askResponse === "messageResponse" && lastAskMessage?.ask !== "completion_result") { + this.isCompleted = false + } this.askResponse = askResponse this.askResponseText = text this.askResponseImages = images @@ -501,8 +534,8 @@ export class Task extends EventEmitter { } if (partial !== undefined) { + // Handles partial messages const lastMessage = this.clineMessages.at(-1) - const isUpdatingPreviousPartial = lastMessage && lastMessage.partial && lastMessage.type === "say" && lastMessage.say === type @@ -521,7 +554,7 @@ export class Task extends EventEmitter { if (!options.isNonInteractive) { this.lastMessageTs = sayTs } - + // For a new partial message, completion is set only when it's finalized. await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images, partial }) } } else { @@ -532,6 +565,10 @@ export class Task extends EventEmitter { if (!options.isNonInteractive) { this.lastMessageTs = lastMessage.ts } + // If this is the final part of a "completion_result" message, mark as completed. + if (type === "completion_result") { + this.isCompleted = true + } lastMessage.text = text lastMessage.images = images @@ -551,7 +588,11 @@ export class Task extends EventEmitter { if (!options.isNonInteractive) { this.lastMessageTs = sayTs } - + // If this is a new, complete "completion_result" message (being added as partial initially but immediately finalized), mark as completed. + // This case might be rare if "completion_result" is always non-partial or ask. + if (type === "completion_result") { + this.isCompleted = true + } await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images }) } } @@ -566,7 +607,10 @@ export class Task extends EventEmitter { if (!options.isNonInteractive) { this.lastMessageTs = sayTs } - + // If this is a new, non-partial "completion_result" message, mark as completed. + if (type === "completion_result") { + this.isCompleted = true + } await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images, checkpoint }) } } @@ -584,6 +628,9 @@ export class Task extends EventEmitter { // Start / Abort / Resume private async startTask(task?: string, images?: string[]): Promise { + if (this.isCompleted && (task || (images && images.length > 0))) { + this.isCompleted = false + } // `conversationHistory` (for API) and `clineMessages` (for webview) // need to be in sync. // If the extension process were killed, then on restart the @@ -691,6 +738,9 @@ export class Task extends EventEmitter { let responseImages: string[] | undefined if (response === "messageResponse") { await this.say("user_feedback", text, images) + if (this.isCompleted) { + this.isCompleted = false + } responseText = text responseImages = images } diff --git a/src/exports/roo-code.d.ts b/src/exports/roo-code.d.ts index 206e41e8c94..896f5251051 100644 --- a/src/exports/roo-code.d.ts +++ b/src/exports/roo-code.d.ts @@ -55,6 +55,7 @@ type GlobalSettings = { size?: number | undefined workspace?: string | undefined parent_task_id?: string | undefined + completed?: boolean | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined @@ -769,6 +770,7 @@ type IpcMessage = size?: number | undefined workspace?: string | undefined parent_task_id?: string | undefined + completed?: boolean | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined @@ -1233,6 +1235,7 @@ type TaskCommand = size?: number | undefined workspace?: string | undefined parent_task_id?: string | undefined + completed?: boolean | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined diff --git a/src/exports/types.ts b/src/exports/types.ts index f186318f6cf..3148f4c1385 100644 --- a/src/exports/types.ts +++ b/src/exports/types.ts @@ -55,6 +55,7 @@ type GlobalSettings = { size?: number | undefined workspace?: string | undefined parent_task_id?: string | undefined + completed?: boolean | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined @@ -783,6 +784,7 @@ type IpcMessage = size?: number | undefined workspace?: string | undefined parent_task_id?: string | undefined + completed?: boolean | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined @@ -1249,6 +1251,7 @@ type TaskCommand = size?: number | undefined workspace?: string | undefined parent_task_id?: string | undefined + completed?: boolean | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined diff --git a/src/schemas/index.ts b/src/schemas/index.ts index ac377e1df47..b95539114f2 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -151,6 +151,7 @@ export const historyItemSchema = z.object({ size: z.number().optional(), workspace: z.string().optional(), parent_task_id: z.string().optional(), + completed: z.boolean().optional(), }) export type HistoryItem = z.infer diff --git a/webview-ui/src/components/history/HistoryPreview.tsx b/webview-ui/src/components/history/HistoryPreview.tsx index 5e992a320cd..a13243bb5b6 100644 --- a/webview-ui/src/components/history/HistoryPreview.tsx +++ b/webview-ui/src/components/history/HistoryPreview.tsx @@ -2,9 +2,7 @@ import { memo } from "react" import { vscode } from "@/utils/vscode" import { formatLargeNumber, formatDate } from "@/utils/format" -import { cn } from "@/lib/utils" // Added for cn utility -import { useExtensionState } from "@/context/ExtensionStateContext" // Added for completion status -import { ClineMessage } from "@roo/shared/ExtensionMessage" // Added for ClineMessage type +import { useExtensionState } from "@/context/ExtensionStateContext" import { CopyButton } from "./CopyButton" import { useTaskSearch, HierarchicalHistoryItem } from "./useTaskSearch" // Updated import @@ -13,7 +11,7 @@ import { Coins, ChevronRight } from "lucide-react" // Added ChevronRight for chi const HistoryPreview = () => { const { tasks, showAllWorkspaces } = useTaskSearch() - const { clineMessages, currentTaskItem } = useExtensionState() + useExtensionState() return ( <> @@ -21,21 +19,12 @@ const HistoryPreview = () => { {tasks.length !== 0 && ( <> {tasks.slice(0, 3).map((item: HierarchicalHistoryItem) => { - let isCompleted = false - if (item.id === currentTaskItem?.id && clineMessages) { - isCompleted = clineMessages.some( - (msg: ClineMessage) => - (msg.type === "ask" && msg.ask === "completion_result") || - (msg.type === "say" && msg.say === "completion_result"), - ) - } + // Use the completed flag directly from the item + const isTaskMarkedCompleted = item.completed ?? false return (
vscode.postMessage({ type: "showTaskWithId", text: item.id })}>
@@ -50,8 +39,11 @@ const HistoryPreview = () => {
= memo( currentTaskId, }) => { const [isOpen, setIsOpen] = useState(false) - let isCompleted = false - // Completion status is only checked if the item is the currently active task - if (item.id === currentTaskId && currentTaskMessages) { - isCompleted = currentTaskMessages.some( - ( - msg: ClineMessage, // Explicitly type msg - ) => - (msg.type === "ask" && msg.ask === "completion_result") || - (msg.type === "say" && msg.say === "completion_result"), - ) - } + // Use the completed flag directly from the item + const isTaskMarkedCompleted = item.completed ?? false const content = (
0, // Reduced padding for child tasks })} style={{ marginLeft: level * 20 }}> {isSelectionMode && ( @@ -130,7 +122,9 @@ const TaskDisplayItem: React.FC = memo(
= memo( dangerouslySetInnerHTML={{ __html: item.task }} />
-
+ {level === 0 && ( // Only show tokens info for parent tasks
- - {t("history:tokensLabel")} - - - - {formatLargeNumber(item.tokensIn || 0)} - - - + {t("history:tokensLabel")} + + - {formatLargeNumber(item.tokensOut || 0)} - -
- {!item.totalCost && !isSelectionMode && ( -
- - + display: "flex", + alignItems: "center", + gap: "3px", + color: "var(--vscode-descriptionForeground)", + }}> + + {formatLargeNumber(item.tokensIn || 0)} + + + + {formatLargeNumber(item.tokensOut || 0)} +
- )} -
+ {!item.totalCost && !isSelectionMode && ( +
+ + +
+ )} +
+ )} - {!!item.cacheWrites && ( + {level === 0 && !!item.cacheWrites && (
= memo(
)} - {!!item.totalCost && ( + {level === 0 && !!item.totalCost && (
Date: Fri, 16 May 2025 20:46:15 -0700 Subject: [PATCH 03/11] fix: Ensure parent_task_id persistence and new task history visibility - Modifies task saving logic to prioritize an existing `parent_task_id` from storage, preventing it from being overwritten. A new `parent_task_id` is set only if none exists in the stored `HistoryItem`. - Resolves an issue where new tasks were not appearing in history. The `getTaskWithId` method would throw a "Task not found" error for new tasks, halting the save process. This error is now handled gracefully, allowing new tasks to be correctly added to history. Signed-off-by: Eric Wheeler --- src/core/task/Task.ts | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 30e54b2def6..e971d95dbc1 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -9,7 +9,7 @@ import pWaitFor from "p-wait-for" import { serializeError } from "serialize-error" // schemas -import { TokenUsage, ToolUsage, ToolName } from "../../schemas" +import { TokenUsage, ToolUsage, ToolName, HistoryItem } from "../../schemas" // api import { ApiHandler, buildApiHandler } from "../../api" @@ -29,7 +29,7 @@ import { ToolProgressStatus, } from "../../shared/ExtensionMessage" import { getApiMetrics } from "../../shared/getApiMetrics" -import { HistoryItem } from "../../shared/HistoryItem" +// Removed duplicate HistoryItem import, using the one from ../../schemas import { ClineAskResponse } from "../../shared/WebviewMessage" import { defaultModeSlug } from "../../shared/modes" import { DiffStrategy } from "../../shared/tools" @@ -344,13 +344,38 @@ export class Task extends EventEmitter { globalStoragePath: this.globalStoragePath, }) + let effectiveParentTaskId = this.parentTaskId // Default to instance's parentTaskId + + // Check existing history for parent_task_id + const provider = this.providerRef.deref() + if (provider) { + try { + const taskData = await provider.getTaskWithId(this.taskId) + const existingHistoryItem = taskData?.historyItem + if (existingHistoryItem && existingHistoryItem.parent_task_id) { + effectiveParentTaskId = existingHistoryItem.parent_task_id + } + } catch (error: any) { + // If task is not found, it's a new task. We'll proceed with `this.parentTaskId` as `effectiveParentTaskId`. + // Log other errors, but don't let them block the task saving process if it's just "Task not found". + if (error.message !== "Task not found") { + console.warn( + `Error fetching task ${this.taskId} during parent_task_id check (this may be a new task):`, + error, + ) + // Optionally, re-throw if it's a critical error not related to "Task not found" + // For now, we'll allow proceeding to ensure new tasks are saved. + } + } + } + const metadataOptions: Parameters[0] = { messages: this.clineMessages, taskId: this.taskId, taskNumber: this.taskNumber, globalStoragePath: this.globalStoragePath, workspace: this.cwd, - parentTaskId: this.parentTaskId, + parentTaskId: effectiveParentTaskId, // Use the determined parentTaskId } if (this.isCompleted) { From 663cc7a5797762973b0e34026d55d96f3f6d67d1 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 16 May 2025 21:10:40 -0700 Subject: [PATCH 04/11] feat: Refactor HistoryView task item display and add expand/collapse all This commit introduces several enhancements to the task history UI: - Refactored the task item display into a reusable `TaskItemHeader` component for consistent rendering of task metadata and action buttons. - Standardized the appearance of action icons (Copy, Export, Delete, Expand/Collapse All) for size, color, and opacity. - Implemented a toggle button (`codicon-list-tree`) to recursively expand and collapse all child tasks within a parent task. - Adjusted spacing and layout for a more compact and visually consistent header. - Ensured task size (using `prettyBytes`) is displayed for all tasks in the header. - Removed parentheses from metadata displays (tokens, cost, cache) for a cleaner look. Signed-off-by: Eric Wheeler --- .../src/components/history/HistoryView.tsx | 442 ++++++++---------- 1 file changed, 202 insertions(+), 240 deletions(-) diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index d78742191df..290d30de211 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -25,6 +25,122 @@ type HistoryViewProps = { type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant" +// Props for the new TaskItemHeader component +interface TaskItemHeaderProps { + item: HierarchicalHistoryItem + isSelectionMode: boolean + t: (key: string, options?: any) => string + setDeleteTaskId: (taskId: string | null) => void + isOpen?: boolean // For chevron icon state + onToggleOpen?: () => void // For chevron click + onExpandAllChildren?: () => void // Renamed for clarity +} + +// New TaskItemHeader component +const TaskItemHeader: React.FC = ({ + item, + isSelectionMode, + t, + setDeleteTaskId, + isOpen, + onToggleOpen, + onExpandAllChildren, // Renamed +}) => { + const iconStyle: React.CSSProperties = { + fontSize: "11px", + fontWeight: "bold", + color: "var(--vscode-descriptionForeground)", + } + const iconStyleWithMargin: React.CSSProperties = { ...iconStyle, marginBottom: "-1.5px" } // Adjusted margin for better alignment + + return ( +
+
+ {" "} + {/* Reduced gap-x-2 to gap-x-1.5 */} + {item.children && item.children.length > 0 && ( + <> + { + e.stopPropagation() + onToggleOpen?.() + }} + /> + { + e.stopPropagation() + onExpandAllChildren?.() // Renamed + }} + /> + + )} + + {formatDate(item.ts)} + + {/* Tokens Info */} + {(item.tokensIn || item.tokensOut) && ( + + + {formatLargeNumber(item.tokensIn || 0)} + {" "} + {/* Reduced ml-1 to ml-0.5 */} + {formatLargeNumber(item.tokensOut || 0)} + + )} + {/* Cost Info */} + {!!item.totalCost && ( + ${item.totalCost.toFixed(4)} + )} + {/* Cache Info */} + {!!item.cacheWrites && ( + + + {formatLargeNumber(item.cacheWrites || 0)} + {" "} + {/* Reduced ml-1 to ml-0.5 */} + {formatLargeNumber(item.cacheReads || 0)} + + )} + {/* Size Info */} + {item.size && {prettyBytes(item.size)}} +
+ {/* Action Buttons */} + {!isSelectionMode && ( +
+ {" "} + {/* Reduced gap-1 to gap-0 */} + + + +
+ )} +
+ ) +} + // Define TaskDisplayItem component interface TaskDisplayItemProps { item: HierarchicalHistoryItem @@ -38,6 +154,7 @@ interface TaskDisplayItemProps { t: (key: string, options?: any) => string currentTaskMessages: ClineMessage[] | undefined currentTaskId: string | undefined + expandAllTrigger?: number // New prop for cascading open state (e.g., a timestamp) } const TaskDisplayItem: React.FC = memo( @@ -53,16 +170,30 @@ const TaskDisplayItem: React.FC = memo( t, currentTaskMessages, currentTaskId, + expandAllTrigger, // This is the trigger received from the parent for IT to open and propagate }) => { - const [isOpen, setIsOpen] = useState(false) + const [isOpen, setIsOpen] = useState(false) // Individual open state for this item + const [expandAllSignalForChildren, setExpandAllSignalForChildren] = useState() + + React.useEffect(() => { + if (expandAllTrigger) { + setIsOpen(true) + // Propagate the exact same trigger to children. + // This ensures all descendants opened by a single "expand all" click share the same signal. + setExpandAllSignalForChildren(expandAllTrigger) + } + }, [expandAllTrigger]) // Only re-run if the trigger from parent changes + // Use the completed flag directly from the item const isTaskMarkedCompleted = item.completed ?? false - const content = ( + // taskMeta is no longer needed. + + const taskPrimaryContent = (
0, // Reduced padding for child tasks + "py-1 px-3": level > 0, })} style={{ marginLeft: level * 20 }}> {isSelectionMode && ( @@ -79,47 +210,25 @@ const TaskDisplayItem: React.FC = memo(
)}
-
-
- {item.children && item.children.length > 0 && ( - { - e.stopPropagation() // Prevent task click when toggling collapse - setIsOpen(!isOpen) - }} - /> - )} - - {formatDate(item.ts)} - -
-
- {!isSelectionMode && ( - - )} -
-
+ { + setIsOpen(!isOpen) + // If user manually closes, we might want to stop an ongoing expandAll propagation for THIS branch. + // However, for simplicity, manual toggle only affects this item directly. + // Future: could set expandAllSignalForChildren to undefined here if closing. + }} + onExpandAllChildren={() => { + setIsOpen(true) // Open current item + setExpandAllSignalForChildren(Date.now()) // Send new signal to children + }} + />
= memo( data-testid="task-content" dangerouslySetInnerHTML={{ __html: item.task }} /> -
- {level === 0 && ( // Only show tokens info for parent tasks -
-
- - {t("history:tokensLabel")} - - - - {formatLargeNumber(item.tokensIn || 0)} - - - - {formatLargeNumber(item.tokensOut || 0)} - -
- {!item.totalCost && !isSelectionMode && ( -
- - -
- )} -
- )} - - {level === 0 && !!item.cacheWrites && ( -
- - {t("history:cacheLabel")} - - - - +{formatLargeNumber(item.cacheWrites || 0)} - - - - {formatLargeNumber(item.cacheReads || 0)} - -
- )} - - {level === 0 && !!item.totalCost && ( -
-
- - {t("history:apiCostLabel")} - - - ${item.totalCost?.toFixed(4)} - -
- {!isSelectionMode && ( -
- - -
- )} -
- )} - - {showAllWorkspaces && item.workspace && ( -
- - {item.workspace} -
- )} -
+ {showAllWorkspaces && item.workspace && ( +
+ + {item.workspace} +
+ )}
) if (item.children && item.children.length > 0) { return ( - - { - if (!isSelectionMode) onTaskClick(item.id) - }}> -
{content}
-
- - {item.children.map((child) => ( - - ))} - -
+ <> + + { + if (!isSelectionMode) onTaskClick(item.id) + }}> +
{taskPrimaryContent}
+
+ + {item.children.map((child) => ( + + ))} + +
+ {/* {taskMeta} no longer exists */} + ) } return ( -
{ - if (isSelectionMode) { - toggleTaskSelection(item.id, !selectedTaskIds.includes(item.id)) - } else { - onTaskClick(item.id) - } - }}> - {content} -
+ <> +
{ + if (isSelectionMode) { + toggleTaskSelection(item.id, !selectedTaskIds.includes(item.id)) + } else { + onTaskClick(item.id) + } + }}> + {taskPrimaryContent} +
+ {/* {!item.children?.length && taskMeta} no longer exists */} + ) }, ) From 0880c01484d8c692681ba8bf506991e46501703c Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 16 May 2025 21:55:41 -0700 Subject: [PATCH 05/11] feat: Hoist history item expansion state and enhance UI This commit implements several key improvements to the task history view: - Hoisted task item expansion state (`expandedItems`, `bulkExpandedRootItems`) and control logic into the `useTaskSearch` hook. This resolves issues with `react-virtuoso` where item expansion state was lost upon scrolling and unmounting/remounting of virtualized items. - `TaskDisplayItem` now receives its expansion state and toggle handlers as props, simplifying its internal logic. - Refactored the task item display into a reusable `TaskItemHeader` component for consistent rendering of task metadata and action buttons. - Standardized the appearance of action icons (Copy, Export, Delete, Expand/Collapse All) for size, color, and opacity. - Implemented a toggle button (`codicon-list-tree`) to recursively expand and collapse all child tasks within a parent task, with state managed by the hoisted logic. - Adjusted spacing and layout for a more compact and visually consistent header. - Ensured task size (using `prettyBytes`) is displayed for all tasks. - Removed parentheses from metadata displays (tokens, cost, cache). - Corrected ESLint `exhaustive-deps` warnings in `useTaskSearch`. Signed-off-by: Eric Wheeler --- .../src/components/history/CopyButton.tsx | 9 +- .../src/components/history/ExportButton.tsx | 2 +- .../src/components/history/HistoryView.tsx | 183 +++++++++++------- .../src/components/history/useTaskSearch.ts | 90 ++++++++- 4 files changed, 210 insertions(+), 74 deletions(-) diff --git a/webview-ui/src/components/history/CopyButton.tsx b/webview-ui/src/components/history/CopyButton.tsx index 2ac8d2157e7..d6ae5a948cd 100644 --- a/webview-ui/src/components/history/CopyButton.tsx +++ b/webview-ui/src/components/history/CopyButton.tsx @@ -30,9 +30,12 @@ export const CopyButton = ({ itemTask }: CopyButtonProps) => { size="icon" title={t("history:copyPrompt")} onClick={onCopy} - data-testid="copy-prompt-button" - className="opacity-50 hover:opacity-100"> - + data-testid="copy-prompt-button"> + {/* Removed opacity classes, removed scale-80. Icon size will be default or controlled by Button's "icon" size. */} + ) } diff --git a/webview-ui/src/components/history/ExportButton.tsx b/webview-ui/src/components/history/ExportButton.tsx index 2089c3dcfb5..2a5e12df62e 100644 --- a/webview-ui/src/components/history/ExportButton.tsx +++ b/webview-ui/src/components/history/ExportButton.tsx @@ -15,7 +15,7 @@ export const ExportButton = ({ itemId }: { itemId: string }) => { e.stopPropagation() vscode.postMessage({ type: "exportTaskWithId", text: itemId }) }}> - + ) } diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 290d30de211..b394fff2f03 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -33,7 +33,8 @@ interface TaskItemHeaderProps { setDeleteTaskId: (taskId: string | null) => void isOpen?: boolean // For chevron icon state onToggleOpen?: () => void // For chevron click - onExpandAllChildren?: () => void // Renamed for clarity + onToggleBulkExpand?: () => void // Changed from onExpandAllChildren + isBulkExpanding?: boolean // To control the toggle button's state } // New TaskItemHeader component @@ -44,20 +45,30 @@ const TaskItemHeader: React.FC = ({ setDeleteTaskId, isOpen, onToggleOpen, - onExpandAllChildren, // Renamed + onToggleBulkExpand, // Changed + isBulkExpanding, // Added }) => { - const iconStyle: React.CSSProperties = { - fontSize: "11px", - fontWeight: "bold", + // Standardized icon style + const metadataIconStyle: React.CSSProperties = { + // Renamed for clarity + fontSize: "12px", // Reverted for metadata icons color: "var(--vscode-descriptionForeground)", + verticalAlign: "middle", + } + const metadataIconWithTextAdjustStyle: React.CSSProperties = { ...metadataIconStyle, marginBottom: "-2px" } + + const actionIconStyle: React.CSSProperties = { + // For action buttons like trash + fontSize: "16px", // To match Copy/Export button icon sizes + color: "var(--vscode-descriptionForeground)", + verticalAlign: "middle", } - const iconStyleWithMargin: React.CSSProperties = { ...iconStyle, marginBottom: "-1.5px" } // Adjusted margin for better alignment return (
-
+
{" "} - {/* Reduced gap-x-2 to gap-x-1.5 */} + {/* Reduced gap-x-1.5 to gap-x-1 */} {item.children && item.children.length > 0 && ( <> = ({ isOpen ? "codicon-chevron-down" : "codicon-chevron-right", "cursor-pointer", )} - style={iconStyle} + style={metadataIconStyle} // Use metadataIconStyle onClick={(e) => { e.stopPropagation() onToggleOpen?.() }} /> - { - e.stopPropagation() - onExpandAllChildren?.() // Renamed - }} - /> + {/* Expand all children icon is moved to the right action button group */} )} @@ -89,10 +92,9 @@ const TaskItemHeader: React.FC = ({ {/* Tokens Info */} {(item.tokensIn || item.tokensOut) && ( - + {formatLargeNumber(item.tokensIn || 0)} - {" "} - {/* Reduced ml-1 to ml-0.5 */} + {formatLargeNumber(item.tokensOut || 0)} )} @@ -103,10 +105,9 @@ const TaskItemHeader: React.FC = ({ {/* Cache Info */} {!!item.cacheWrites && ( - + {formatLargeNumber(item.cacheWrites || 0)} - {" "} - {/* Reduced ml-1 to ml-0.5 */} + {formatLargeNumber(item.cacheReads || 0)} )} @@ -118,11 +119,31 @@ const TaskItemHeader: React.FC = ({
{" "} {/* Reduced gap-1 to gap-0 */} + {item.children && item.children.length > 0 && ( + + )}
)} @@ -154,9 +175,19 @@ interface TaskDisplayItemProps { t: (key: string, options?: any) => string currentTaskMessages: ClineMessage[] | undefined currentTaskId: string | undefined - expandAllTrigger?: number // New prop for cascading open state (e.g., a timestamp) + // Props for hoisted state + isExpanded: boolean + isBulkExpanded: boolean + onToggleExpansion: (taskId: string) => void + onToggleBulkExpansion: (taskId: string) => void + // Pass down the maps for children to use + expandedItems: Record + bulkExpandedRootItems: Record } +// explicit signals BULK_EXPAND_SIGNAL and BULK_COLLAPSE_SIGNAL are no longer needed here +// as the logic is handled by the hoisted state and callbacks. + const TaskDisplayItem: React.FC = memo( ({ item, @@ -170,19 +201,17 @@ const TaskDisplayItem: React.FC = memo( t, currentTaskMessages, currentTaskId, - expandAllTrigger, // This is the trigger received from the parent for IT to open and propagate + // Hoisted state props + isExpanded, + isBulkExpanded, + onToggleExpansion, + onToggleBulkExpansion, + // Destructure the maps + expandedItems, + bulkExpandedRootItems, }) => { - const [isOpen, setIsOpen] = useState(false) // Individual open state for this item - const [expandAllSignalForChildren, setExpandAllSignalForChildren] = useState() - - React.useEffect(() => { - if (expandAllTrigger) { - setIsOpen(true) - // Propagate the exact same trigger to children. - // This ensures all descendants opened by a single "expand all" click share the same signal. - setExpandAllSignalForChildren(expandAllTrigger) - } - }, [expandAllTrigger]) // Only re-run if the trigger from parent changes + // Local state for isOpen, expandAllSignalForChildren, isBulkExpandingChildrenState, and useEffect are removed. + // Expansion state is now controlled by `isExpanded` and `isBulkExpanded` props. // Use the completed flag directly from the item const isTaskMarkedCompleted = item.completed ?? false @@ -195,7 +224,8 @@ const TaskDisplayItem: React.FC = memo( "p-3": level === 0, "py-1 px-3": level > 0, })} - style={{ marginLeft: level * 20 }}> + style={{ marginLeft: level * 20 }} // Reverted to inline style for reliable indentation + > {isSelectionMode && (
= memo( isSelectionMode={isSelectionMode} t={t} setDeleteTaskId={setDeleteTaskId} - isOpen={isOpen} // Controlled by single chevron or expandAllTrigger effect - onToggleOpen={() => { - setIsOpen(!isOpen) - // If user manually closes, we might want to stop an ongoing expandAll propagation for THIS branch. - // However, for simplicity, manual toggle only affects this item directly. - // Future: could set expandAllSignalForChildren to undefined here if closing. - }} - onExpandAllChildren={() => { - setIsOpen(true) // Open current item - setExpandAllSignalForChildren(Date.now()) // Send new signal to children - }} + isOpen={isExpanded} // Use hoisted state + onToggleOpen={() => onToggleExpansion(item.id)} // Call hoisted function + onToggleBulkExpand={() => onToggleBulkExpansion(item.id)} // Call hoisted function + isBulkExpanding={isBulkExpanded} // Use hoisted state />
= memo( if (item.children && item.children.length > 0) { return ( <> - + {/* Use isExpanded for open state; onOpenChange calls the hoisted toggle function */} + onToggleExpansion(item.id)}> { @@ -281,7 +305,14 @@ const TaskDisplayItem: React.FC = memo( t={t} currentTaskMessages={currentTaskMessages} currentTaskId={currentTaskId} - expandAllTrigger={expandAllSignalForChildren} // Pass down the signal + // Pass down hoisted state and handlers + isExpanded={expandedItems[child.id] ?? false} // Use the passed down map + isBulkExpanded={bulkExpandedRootItems[child.id] ?? false} // Use the passed down map + onToggleExpansion={onToggleExpansion} + onToggleBulkExpansion={onToggleBulkExpansion} + // Crucially, pass the maps themselves down for further nesting + expandedItems={expandedItems} + bulkExpandedRootItems={bulkExpandedRootItems} /> ))} @@ -319,6 +350,10 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { setLastNonRelevantSort, showAllWorkspaces, setShowAllWorkspaces, + expandedItems, // Destructured from useTaskSearch + bulkExpandedRootItems, // Destructured from useTaskSearch + toggleItemExpansion, // Destructured from useTaskSearch + toggleBulkItemExpansion, // Destructured from useTaskSearch } = useTaskSearch() const { t } = useAppTranslation() // Destructure clineMessages and currentTaskItem (which contains the active task's ID) @@ -516,8 +551,16 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { setDeleteTaskId={setDeleteTaskId} showAllWorkspaces={showAllWorkspaces} t={t} - currentTaskMessages={clineMessages} // Pass active task's messages - currentTaskId={currentTaskItem?.id} // Pass active task's ID from currentTaskItem + currentTaskMessages={clineMessages} + currentTaskId={currentTaskItem?.id} + // Pass hoisted state and handlers + isExpanded={expandedItems[item.id] ?? false} + isBulkExpanded={bulkExpandedRootItems[item.id] ?? false} + onToggleExpansion={toggleItemExpansion} + onToggleBulkExpansion={toggleBulkItemExpansion} + // Pass the maps to the top-level TaskDisplayItems + expandedItems={expandedItems} + bulkExpandedRootItems={bulkExpandedRootItems} />
)} @@ -525,21 +568,23 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { {/* Fixed action bar at bottom - only shown in selection mode with selected items */} - {isSelectionMode && selectedTaskIds.length > 0 && ( -
-
- {t("history:selectedItems", { selected: selectedTaskIds.length, total: tasks.length })} -
-
- - + {isSelectionMode && + selectedTaskIds.length > 0 && + !currentTaskItem && ( // Hide if preview is open +
+
+ {t("history:selectedItems", { selected: selectedTaskIds.length, total: tasks.length })} +
+
+ + +
-
- )} + )} {/* Delete dialog */} {deleteTaskId && ( diff --git a/webview-ui/src/components/history/useTaskSearch.ts b/webview-ui/src/components/history/useTaskSearch.ts index e288c50895d..d3f93e9c806 100644 --- a/webview-ui/src/components/history/useTaskSearch.ts +++ b/webview-ui/src/components/history/useTaskSearch.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react" +import { useState, useEffect, useMemo, useCallback } from "react" // Added useCallback import { Fzf } from "fzf" import { highlightFzfMatch } from "@/utils/highlight" @@ -11,12 +11,44 @@ export interface HierarchicalHistoryItem extends HistoryItem { children?: HierarchicalHistoryItem[] } +// Helper functions defined outside the hook for stability +const getAllDescendantIdsRecursive = (item: HierarchicalHistoryItem): string[] => { + let ids: string[] = [] + if (item.children && item.children.length > 0) { + item.children.forEach((child: HierarchicalHistoryItem) => { + ids.push(child.id) + ids = ids.concat(getAllDescendantIdsRecursive(child)) + }) + } + return ids +} + +const findItemByIdRecursive = ( + currentItems: HierarchicalHistoryItem[], + idToFind: string, +): HierarchicalHistoryItem | null => { + for (const item of currentItems) { + if (item.id === idToFind) { + return item + } + if (item.children) { + const foundInChildren = findItemByIdRecursive(item.children, idToFind) + if (foundInChildren) { + return foundInChildren + } + } + } + return null +} + export const useTaskSearch = () => { const { taskHistory, cwd } = useExtensionState() const [searchQuery, setSearchQuery] = useState("") const [sortOption, setSortOption] = useState("newest") const [lastNonRelevantSort, setLastNonRelevantSort] = useState("newest") const [showAllWorkspaces, setShowAllWorkspaces] = useState(false) + const [expandedItems, setExpandedItems] = useState>({}) + const [bulkExpandedRootItems, setBulkExpandedRootItems] = useState>({}) useEffect(() => { if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) { @@ -111,6 +143,58 @@ export const useTaskSearch = () => { return sortTasksRecursive(rootTasks) }, [presentableTasks, searchQuery, fzf, sortOption]) + const toggleItemExpansion = useCallback( + (taskId: string) => { + setExpandedItems((prev) => ({ + ...prev, + [taskId]: !prev[taskId], + })) + setBulkExpandedRootItems((prev) => ({ + ...prev, + [taskId]: false, + })) + }, + [setExpandedItems, setBulkExpandedRootItems], // Correct: only depends on setters + ) + + const toggleBulkItemExpansion = useCallback( + (taskId: string) => { + // `tasks` is from useMemo, `expandedItems` is from useState. + // Both are correctly captured here due to being in the dependency array. + const targetItem = findItemByIdRecursive(tasks, taskId) + + if (!targetItem) { + console.warn(`Task item with ID ${taskId} not found for bulk expansion.`) + return + } + + // It's important that setBulkExpandedRootItems and setExpandedItems are called + // in a way that uses the latest state if there are rapid calls. + // Using the functional update form for both setters ensures this. + + setBulkExpandedRootItems((prevBulkExpanded) => { + const isNowBulkExpanding = !prevBulkExpanded[taskId] + + setExpandedItems((currentExpandedItems) => { + const newExpandedItemsState = { ...currentExpandedItems } + newExpandedItemsState[taskId] = isNowBulkExpanding + + const descendants = getAllDescendantIdsRecursive(targetItem) + descendants.forEach((id) => { + newExpandedItemsState[id] = isNowBulkExpanding + }) + return newExpandedItemsState + }) + + return { + ...prevBulkExpanded, + [taskId]: isNowBulkExpanding, + } + }) + }, + [tasks, setExpandedItems, setBulkExpandedRootItems], // Removed expandedItems + ) + return { tasks, searchQuery, @@ -121,5 +205,9 @@ export const useTaskSearch = () => { setLastNonRelevantSort, showAllWorkspaces, setShowAllWorkspaces, + expandedItems, + bulkExpandedRootItems, + toggleItemExpansion, + toggleBulkItemExpansion, } } From c1e1ee7877f0f270cae2b3046bfa0316062f16bf Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Fri, 16 May 2025 22:14:13 -0700 Subject: [PATCH 06/11] feat: Tighten vertical spacing in task history view Adjusted padding and margins in the HistoryView component to create a more compact display of task items. - Standardized vertical padding for all task items. - Minimized space between task headers and their content. - Removed extraneous padding within the task item header. Signed-off-by: Eric Wheeler --- webview-ui/src/components/history/HistoryView.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index b394fff2f03..fe2a5709ad1 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -65,7 +65,9 @@ const TaskItemHeader: React.FC = ({ } return ( -
+
+ {" "} + {/* Added pb-0 */}
{" "} {/* Reduced gap-x-1.5 to gap-x-1 */} @@ -221,8 +223,7 @@ const TaskDisplayItem: React.FC = memo( const taskPrimaryContent = (
0, + "pt-0.5 pb-0.5 px-3": true, // Reduced top/bottom padding to 0.125rem for all levels })} style={{ marginLeft: level * 20 }} // Reverted to inline style for reliable indentation > @@ -239,7 +240,9 @@ const TaskDisplayItem: React.FC = memo( />
)} -
+
+ {" "} + {/* Ensure no gap between header and content */} = memo( isBulkExpanding={isBulkExpanded} // Use hoisted state />
Date: Fri, 16 May 2025 22:23:28 -0700 Subject: [PATCH 07/11] feat: Adjust task history item spacing and button opacity - Increased horizontal gap between metadata items in the task header. - Set action buttons in the task header to 50% opacity, transitioning to 100% on hover. - Updated CopyButton and ExportButton components to accept and apply a className prop, resolving TypeScript errors. Signed-off-by: Eric Wheeler --- webview-ui/src/components/history/CopyButton.tsx | 4 +++- webview-ui/src/components/history/ExportButton.tsx | 3 ++- webview-ui/src/components/history/HistoryView.tsx | 10 ++++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/webview-ui/src/components/history/CopyButton.tsx b/webview-ui/src/components/history/CopyButton.tsx index d6ae5a948cd..31e4a0a2bb4 100644 --- a/webview-ui/src/components/history/CopyButton.tsx +++ b/webview-ui/src/components/history/CopyButton.tsx @@ -7,9 +7,10 @@ import { useAppTranslation } from "@/i18n/TranslationContext" type CopyButtonProps = { itemTask: string + className?: string } -export const CopyButton = ({ itemTask }: CopyButtonProps) => { +export const CopyButton = ({ itemTask, className }: CopyButtonProps) => { const { isCopied, copy } = useClipboard() const { t } = useAppTranslation() @@ -28,6 +29,7 @@ export const CopyButton = ({ itemTask }: CopyButtonProps) => { )} - - + +