diff --git a/src/core/config/__tests__/ContextProxy.test.ts b/src/core/config/__tests__/ContextProxy.test.ts index bdd3d5ddc5..08ab5f71d9 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 9784e62295..27458a0453 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -17,6 +17,9 @@ export type TaskMetadataOptions = { taskNumber: number globalStoragePath: string workspace: string + parentTaskId?: string + setCompleted?: boolean + unsetCompleted?: boolean } export async function taskMetadata({ @@ -25,6 +28,9 @@ export async function taskMetadata({ taskNumber, globalStoragePath, workspace, + parentTaskId, + setCompleted, + unsetCompleted, }: TaskMetadataOptions) { const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) const taskMessage = messages[0] // First message is always the task say. @@ -45,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, @@ -57,6 +70,8 @@ export async function taskMetadata({ totalCost: tokenUsage.totalCost, 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 918041e459..fe3e382edb 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" @@ -117,12 +117,14 @@ export class Task extends EventEmitter { readonly rootTask: Task | undefined = undefined readonly parentTask: Task | undefined = undefined + readonly parentTaskId?: string readonly taskNumber: number readonly workspacePath: string providerRef: WeakRef private readonly globalStoragePath: string abort: boolean = false + private isCompleted: boolean = false didFinishAbortingStream = false abandoned = false isInitialized = false @@ -208,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 @@ -237,6 +240,9 @@ export class Task extends EventEmitter { this.rootTask = rootTask this.parentTask = parentTask + if (parentTask) { + this.parentTaskId = parentTask.taskId + } this.taskNumber = taskNumber if (historyItem) { @@ -315,7 +321,7 @@ export class Task extends EventEmitter { private async addToClineMessages(message: ClineMessage) { this.clineMessages.push(message) - await this.providerRef.deref()?.postStateToWebview() + // Removed direct call to postStateToWebview(), ClineProvider.updateTaskHistory will handle it this.emit("message", { action: "created", message }) await this.saveClineMessages() } @@ -338,13 +344,47 @@ export class Task extends EventEmitter { globalStoragePath: this.globalStoragePath, }) - const { historyItem, tokenUsage } = await taskMetadata({ + 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: effectiveParentTaskId, // Use the determined parentTaskId + } + + if (this.isCompleted) { + metadataOptions.setCompleted = true + } else { + metadataOptions.unsetCompleted = true + } + + const { historyItem, tokenUsage } = await taskMetadata(metadataOptions) this.emit("taskTokenUsageUpdated", this.taskId, tokenUsage) @@ -449,6 +489,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) { @@ -459,6 +505,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 @@ -467,6 +523,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 @@ -496,8 +559,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 @@ -516,7 +579,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 { @@ -527,6 +590,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 @@ -546,7 +613,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 }) } } @@ -561,7 +632,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 }) } } @@ -579,6 +653,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 @@ -686,6 +763,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/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 1c63c95196..101f086efe 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1112,8 +1112,29 @@ export class ClineProvider extends EventEmitter implements // this function deletes a task from task hidtory, and deletes it's checkpoints and delete the task folder async deleteTaskWithId(id: string) { try { - // get the task directory full path - const { taskDirPath } = await this.getTaskWithId(id) + // Get the task to be deleted to access its parent_task_id + const { historyItem: deletedTaskHistoryItem, taskDirPath } = await this.getTaskWithId(id) + const grandparentTaskId = deletedTaskHistoryItem.parent_task_id + + let currentTaskHistory = (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) || [] + + // Create a new array with reparented children + const reparentedTaskHistory = currentTaskHistory.map((task) => { + if (task.parent_task_id === id) { + this.log(`Reparenting task ${task.id} from ${id} to ${grandparentTaskId ?? "undefined (root)"}`) + return { ...task, parent_task_id: grandparentTaskId } + } + return task + }) + + // Filter out the deleted task: this is in lieu of `deleteTaskFromState(id)` + const finalTaskHistory = reparentedTaskHistory.filter((task) => task.id !== id) + + // Update global state with the final list + await this.updateGlobalState("taskHistory", finalTaskHistory) + + // Post the final state to the webview, ensuring UI reflects all changes at once + await this.postStateToWebview() // remove task from stack if it's the current task if (id === this.getCurrentCline()?.taskId) { @@ -1122,9 +1143,6 @@ export class ClineProvider extends EventEmitter implements await this.finishSubTask(t("common:tasks.deleted")) } - // delete task from the task history state - await this.deleteTaskFromState(id) - // Delete associated shadow repository or branch. // TODO: Store `workspaceDir` in the `HistoryItem` object. const globalStorageDir = this.contextProxy.globalStorageUri.fsPath @@ -1428,6 +1446,12 @@ export class ClineProvider extends EventEmitter implements } await this.updateGlobalState("taskHistory", history) + + // Post the updated state to all active webview instances + for (const instance of ClineProvider.activeInstances) { + await instance.postStateToWebview() + } + return history } diff --git a/src/exports/roo-code.d.ts b/src/exports/roo-code.d.ts index 2961f17489..896f525105 100644 --- a/src/exports/roo-code.d.ts +++ b/src/exports/roo-code.d.ts @@ -54,6 +54,8 @@ type GlobalSettings = { totalCost: number size?: number | undefined workspace?: string | undefined + parent_task_id?: string | undefined + completed?: boolean | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined @@ -767,6 +769,8 @@ type IpcMessage = totalCost: number size?: number | undefined workspace?: string | undefined + parent_task_id?: string | undefined + completed?: boolean | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined @@ -1230,6 +1234,8 @@ type TaskCommand = totalCost: number 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 47cc16a749..3148f4c138 100644 --- a/src/exports/types.ts +++ b/src/exports/types.ts @@ -54,6 +54,8 @@ type GlobalSettings = { totalCost: number size?: number | undefined workspace?: string | undefined + parent_task_id?: string | undefined + completed?: boolean | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined @@ -781,6 +783,8 @@ type IpcMessage = totalCost: number size?: number | undefined workspace?: string | undefined + parent_task_id?: string | undefined + completed?: boolean | undefined }[] | undefined autoApprovalEnabled?: boolean | undefined @@ -1246,6 +1250,8 @@ type TaskCommand = totalCost: number 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 209bc67d2c..b95539114f 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -150,6 +150,8 @@ export const historyItemSchema = z.object({ totalCost: z.number(), 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/CopyButton.tsx b/webview-ui/src/components/history/CopyButton.tsx index 2ac8d2157e..31e4a0a2bb 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,11 +29,15 @@ export const CopyButton = ({ itemTask }: CopyButtonProps) => { ) } diff --git a/webview-ui/src/components/history/ExportButton.tsx b/webview-ui/src/components/history/ExportButton.tsx index 2089c3dcfb..280193ae6c 100644 --- a/webview-ui/src/components/history/ExportButton.tsx +++ b/webview-ui/src/components/history/ExportButton.tsx @@ -2,7 +2,7 @@ import { vscode } from "@/utils/vscode" import { Button } from "@/components/ui" import { useAppTranslation } from "@/i18n/TranslationContext" -export const ExportButton = ({ itemId }: { itemId: string }) => { +export const ExportButton = ({ itemId, className }: { itemId: string; className?: string }) => { const { t } = useAppTranslation() return ( @@ -10,12 +10,13 @@ export const ExportButton = ({ itemId }: { itemId: string }) => { data-testid="export" variant="ghost" size="icon" + className={className} title={t("history:exportTask")} onClick={(e) => { e.stopPropagation() vscode.postMessage({ type: "exportTaskWithId", text: itemId }) }}> - + ) } diff --git a/webview-ui/src/components/history/HistoryPreview.tsx b/webview-ui/src/components/history/HistoryPreview.tsx index 080cbff3e2..a13243bb5b 100644 --- a/webview-ui/src/components/history/HistoryPreview.tsx +++ b/webview-ui/src/components/history/HistoryPreview.tsx @@ -2,62 +2,76 @@ import { memo } from "react" import { vscode } from "@/utils/vscode" import { formatLargeNumber, formatDate } from "@/utils/format" +import { useExtensionState } from "@/context/ExtensionStateContext" 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() + 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) => { + // Use the completed flag directly from the item + const isTaskMarkedCompleted = item.completed ?? false + 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 bcb86a8d13..173ff17175 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,326 @@ 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 + onToggleBulkExpand?: () => void // Changed from onExpandAllChildren + isBulkExpanding?: boolean // To control the toggle button's state +} + +// New TaskItemHeader component +const TaskItemHeader: React.FC = ({ + item, + isSelectionMode, + t, + setDeleteTaskId, + isOpen, + onToggleOpen, + onToggleBulkExpand, // Changed + isBulkExpanding, // Added +}) => { + // 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", + } + + return ( +
+ {" "} + {/* Added pb-0 */} +
+ {" "} + {/* Increased gap-x-1 to gap-x-2 */} {/* Reduced gap-x-1.5 to gap-x-1 */} + {item.children && item.children.length > 0 && ( + <> + { + e.stopPropagation() + onToggleOpen?.() + }} + /> + {/* Expand all children icon is moved to the right action button group */} + + )} + + {formatDate(item.ts)} + + {/* Tokens Info */} + {(item.tokensIn || item.tokensOut) && ( + + + {formatLargeNumber(item.tokensIn || 0)} + + {formatLargeNumber(item.tokensOut || 0)} + + )} + {/* Cost Info */} + {!!item.totalCost && ( + ${item.totalCost.toFixed(4)} + )} + {/* Cache Info */} + {!!item.cacheWrites && ( + + + {formatLargeNumber(item.cacheWrites || 0)} + + {formatLargeNumber(item.cacheReads || 0)} + + )} + {/* Size Info */} + {item.size && {prettyBytes(item.size)}} +
+ {/* Action Buttons */} + {!isSelectionMode && ( +
+ {" "} + {/* Reduced gap-1 to gap-0 */} + {item.children && item.children.length > 0 && ( + + )} + + + +
+ )} +
+ ) +} + +// 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 + // 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, + level, + isSelectionMode, + selectedTaskIds, + toggleTaskSelection, + onTaskClick, + setDeleteTaskId, + showAllWorkspaces, + t, + currentTaskMessages, + currentTaskId, + // Hoisted state props + isExpanded, + isBulkExpanded, + onToggleExpansion, + onToggleBulkExpansion, + // Destructure the maps + expandedItems, + bulkExpandedRootItems, + }) => { + // 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 + + // taskMeta is no longer needed. + + const taskPrimaryContent = ( +
+ {isSelectionMode && ( +
{ + e.stopPropagation() + }}> + toggleTaskSelection(item.id, checked === true)} + variant="description" + /> +
+ )} +
+ {" "} + {/* Ensure no gap between header and content */} + onToggleExpansion(item.id)} // Call hoisted function + onToggleBulkExpand={() => onToggleBulkExpansion(item.id)} // Call hoisted function + isBulkExpanding={isBulkExpanded} // Use hoisted state + /> +
+ {showAllWorkspaces && item.workspace && ( +
+ + {item.workspace} +
+ )} +
+
+ ) + + if (item.children && item.children.length > 0) { + return ( + <> + {/* Use isExpanded for open state; onOpenChange calls the hoisted toggle function */} + onToggleExpansion(item.id)}> + { + 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) + } + }}> + {taskPrimaryContent} +
+ {/* {!item.children?.length && taskMeta} no longer exists */} + + ) + }, +) + const HistoryView = ({ onDone }: HistoryViewProps) => { const { tasks, @@ -33,8 +355,14 @@ 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) + const { clineMessages, currentTaskItem } = useExtensionState() const [deleteTaskId, setDeleteTaskId] = useState(null) const [isSelectionMode, setIsSelectionMode] = useState(false) @@ -99,7 +427,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} + 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} + />
)} /> {/* 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 47d5c3719c..9ad05a5d1e 100644 --- a/webview-ui/src/components/history/useTaskSearch.ts +++ b/webview-ui/src/components/history/useTaskSearch.ts @@ -1,17 +1,54 @@ -import { useState, useEffect, useMemo } from "react" +import { useState, useEffect, useMemo, useCallback } from "react" // Added useCallback 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[] +} + +// 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) { @@ -38,7 +75,7 @@ export const useTaskSearch = () => { }, [presentableTasks]) const tasks = useMemo(() => { - let results = presentableTasks + let results: HierarchicalHistoryItem[] = [...presentableTasks] if (searchQuery) { const searchResults = fzf.find(searchQuery) @@ -57,27 +94,159 @@ 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]) + useEffect(() => { + // This effect ensures that newly added children of bulk-expanded parents are also expanded. + setExpandedItems((prevExpanded) => { + let newExpanded = { ...prevExpanded } + let changed = false + + // Helper to create a flat map of all tasks for efficient lookup by ID + const allTasksMap = new Map() + const populateTaskMapRecursive = (currentTaskList: HierarchicalHistoryItem[]) => { + for (const item of currentTaskList) { + allTasksMap.set(item.id, item) + if (item.children) { + populateTaskMapRecursive(item.children) + } + } + } + populateTaskMapRecursive(tasks) // `tasks` is the hierarchical list from useMemo + + const isAncestorBulkExpanded = ( + itemId: string | undefined, + currentBulkExpandedItems: Record, + ): boolean => { + if (!itemId) return false + const item = allTasksMap.get(itemId) + if (!item) return false + + if (currentBulkExpandedItems[item.id]) { + return true + } + // Recursively check the parent + return isAncestorBulkExpanded(item.parent_task_id, currentBulkExpandedItems) + } + + const applyRecursiveExpansion = (currentTaskList: HierarchicalHistoryItem[]) => { + for (const item of currentTaskList) { + // If the item itself or any of its ancestors are bulk-expanded, and it's not already expanded + if (!newExpanded[item.id] && isAncestorBulkExpanded(item.id, bulkExpandedRootItems)) { + newExpanded[item.id] = true + changed = true + } + if (item.children && item.children.length > 0) { + applyRecursiveExpansion(item.children) + } + } + } + + applyRecursiveExpansion(tasks) + + return changed ? newExpanded : prevExpanded + }) + }, [tasks, bulkExpandedRootItems, setExpandedItems]) + + 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, @@ -88,5 +257,9 @@ export const useTaskSearch = () => { setLastNonRelevantSort, showAllWorkspaces, setShowAllWorkspaces, + expandedItems, + bulkExpandedRootItems, + toggleItemExpansion, + toggleBulkItemExpansion, } }