Compare commits

...

1 Commits

Author SHA1 Message Date
Jose R. Perez e8f2326139 feat: recent vs workspace tasks view and labeling enhancements 2026-03-13 12:57:29 -04:00
5 changed files with 274 additions and 67 deletions
+2
View File
@@ -103,6 +103,8 @@ message TaskItem {
int32 cache_writes = 9;
int32 cache_reads = 10;
string model_id = 11;
bool is_current_workspace = 12;
string workspace_name = 13;
}
// Request for ask response operation
+25 -18
View File
@@ -15,7 +15,28 @@ export async function getTaskHistory(controller: Controller, request: GetTaskHis
// Get task history from global state
const taskHistory = controller.stateManager.getGlobalStateKey("taskHistory")
const workspacePath = await getWorkspacePath()
// Ensure workspace manager is initialized for workspace path detection
const wm = await controller.ensureWorkspaceManager()
const workspacePath = wm?.getPrimaryRoot()?.path || (await getWorkspacePath().catch(() => ""))
const getWorkspaceName = (item: (typeof taskHistory)[number]): string => {
const p = item.cwdOnTaskInitialization || item.shadowGitConfigWorkTree
if (!p) return ""
const segments = p.replace(/\\/g, "/").split("/").filter(Boolean)
return segments[segments.length - 1] || ""
}
const isTaskInCurrentWorkspace = (item: (typeof taskHistory)[number]) => {
if (item.cwdOnTaskInitialization && arePathsEqual(item.cwdOnTaskInitialization, workspacePath)) {
return true
}
if (item.shadowGitConfigWorkTree && arePathsEqual(item.shadowGitConfigWorkTree, workspacePath)) {
return true
}
return false
}
// Apply filters
let filteredTasks = taskHistory.filter((item) => {
@@ -32,23 +53,7 @@ export async function getTaskHistory(controller: Controller, request: GetTaskHis
// Apply current workspace filter if requested
if (currentWorkspaceOnly) {
let isInWorkspace = false
// First check the cwdOnTaskInitialization property - Only present on tasks from this change forward
if (item.cwdOnTaskInitialization) {
if (arePathsEqual(item.cwdOnTaskInitialization, workspacePath)) {
isInWorkspace = true
}
}
// For tasks without cwdOnTaskInitialization, check the older shadowGitConfigWorkTree property
if (!isInWorkspace && item.shadowGitConfigWorkTree) {
if (arePathsEqual(item.shadowGitConfigWorkTree, workspacePath)) {
isInWorkspace = true
}
}
if (!isInWorkspace) {
if (!isTaskInCurrentWorkspace(item)) {
return false
}
}
@@ -105,6 +110,8 @@ export async function getTaskHistory(controller: Controller, request: GetTaskHis
cacheWrites: item.cacheWrites || 0,
cacheReads: item.cacheReads || 0,
modelId: item.modelId || "",
isCurrentWorkspace: isTaskInCurrentWorkspace(item),
workspaceName: getWorkspaceName(item),
}))
return TaskHistoryArray.create({
@@ -1,5 +1,6 @@
import { HistoryItem } from "@shared/HistoryItem"
import { StringRequest } from "@shared/proto/cline/common"
import { memo } from "react"
import { memo, useCallback, useMemo, useState } from "react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { TaskServiceClient } from "@/services/grpc-client"
@@ -7,8 +8,76 @@ type HistoryPreviewProps = {
showHistoryView: () => void
}
type FilterMode = "all" | "workspace"
const STORAGE_KEY = "historyPreviewFilter"
function getSavedFilter(): FilterMode {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved === "workspace") return "workspace"
} catch {}
return "all"
}
/**
* Extract a display name for the workspace from a task's path fields.
*/
function getWorkspaceLabel(item: HistoryItem): string {
const p = item.cwdOnTaskInitialization || item.shadowGitConfigWorkTree
if (!p) return "Unknown Workspace"
const segments = p.replace(/\\/g, "/").split("/").filter(Boolean)
return segments[segments.length - 1] || "Unknown Workspace"
}
/**
* Group tasks by workspace label while preserving order.
*/
function groupByWorkspace(items: HistoryItem[]): { label: string; tasks: HistoryItem[] }[] {
const groups: { label: string; tasks: HistoryItem[] }[] = []
const seen = new Map<string, number>()
for (const item of items) {
const label = getWorkspaceLabel(item)
const idx = seen.get(label)
if (idx !== undefined) {
groups[idx].tasks.push(item)
} else {
seen.set(label, groups.length)
groups.push({ label, tasks: [item] })
}
}
return groups
}
/**
* Extract workspace folder name from workspaceRoots paths.
*/
function getWorkspaceFolderName(workspaceRoots: Array<{ path?: string; name?: string }>): string {
if (!workspaceRoots || workspaceRoots.length === 0) return ""
const root = workspaceRoots[0]
if (root.name) return root.name
const p = root.path
if (!p) return ""
const segments = p.replace(/\\/g, "/").split("/").filter(Boolean)
return segments[segments.length - 1] || ""
}
const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
const { taskHistory } = useExtensionState()
const { taskHistory, workspaceRoots } = useExtensionState()
const [filter, setFilter] = useState<FilterMode>(getSavedFilter)
const workspaceName = useMemo(() => getWorkspaceFolderName(workspaceRoots || []), [workspaceRoots])
const handleFilterChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
const newFilter = e.target.value as FilterMode
setFilter(newFilter)
try {
localStorage.setItem(STORAGE_KEY, newFilter)
} catch {}
}, [])
const handleHistorySelect = (id: string) => {
TaskServiceClient.showTaskWithId(StringRequest.create({ value: id })).catch((error) =>
console.error("Error showing task:", error),
@@ -23,6 +92,36 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
})
}
// Compute workspace path from workspaceRoots for local matching
const currentWorkspacePath = useMemo(() => {
if (!workspaceRoots || workspaceRoots.length === 0) return ""
return workspaceRoots[0]?.path || ""
}, [workspaceRoots])
const isInCurrentWorkspace = useCallback(
(item: HistoryItem) => {
if (!currentWorkspacePath) return false
const taskPath = item.cwdOnTaskInitialization || item.shadowGitConfigWorkTree
if (!taskPath) return false
// Normalize paths for comparison
const normalize = (p: string) => p.replace(/\\/g, "/").replace(/\/+$/, "")
return normalize(taskPath) === normalize(currentWorkspacePath)
},
[currentWorkspacePath],
)
const allValidTasks = useMemo(() => taskHistory.filter((item) => item.ts && item.task), [taskHistory])
const displayTasks = useMemo(() => {
if (filter === "workspace") {
return allValidTasks.filter((item) => isInCurrentWorkspace(item)).slice(0, 5)
}
return [...allValidTasks].sort((a, b) => b.ts - a.ts).slice(0, 5)
}, [allValidTasks, filter, isInCurrentWorkspace])
const workspaceGroups = useMemo(() => groupByWorkspace(displayTasks), [displayTasks])
const hasMultipleWorkspaces = filter === "all" && workspaceGroups.length > 1
return (
<div style={{ flexShrink: 0 }}>
<style>
@@ -33,7 +132,7 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
position: relative;
overflow: hidden;
cursor: pointer;
margin-bottom: 8px;
margin-bottom: 6px;
padding: 10px 12px;
display: flex;
align-items: flex-start;
@@ -100,6 +199,41 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
.history-view-all-btn:hover {
color: var(--vscode-foreground);
}
.history-workspace-divider {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 0 4px 0;
color: var(--vscode-descriptionForeground);
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.history-workspace-divider::after {
content: '';
flex: 1;
height: 1px;
background: color-mix(in srgb, var(--vscode-descriptionForeground) 25%, transparent);
}
.history-filter-select {
background: none;
border: 1px solid color-mix(in srgb, var(--vscode-descriptionForeground) 30%, transparent);
border-radius: 3px;
color: var(--vscode-descriptionForeground);
font-weight: 500;
font-size: 0.8em;
cursor: pointer;
padding: 1px 4px;
margin-left: 6px;
outline: none;
font-family: inherit;
}
.history-filter-select:hover,
.history-filter-select:focus {
color: var(--vscode-foreground);
border-color: color-mix(in srgb, var(--vscode-descriptionForeground) 60%, transparent);
}
`}
</style>
@@ -118,17 +252,24 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
style={{
marginRight: "4px",
transform: "scale(0.9)",
}}></span>
}}
/>
<span
style={{
fontWeight: 500,
fontSize: "0.85em",
textTransform: "uppercase",
}}>
Recent
Recent Tasks
</span>
{workspaceName && (
<select className="history-filter-select" onChange={handleFilterChange} value={filter}>
<option value="all">All</option>
<option value="workspace">{workspaceName}</option>
</select>
)}
</div>
{taskHistory.filter((item) => item.ts && item.task).length > 0 && (
{displayTasks.length > 0 && (
<button
aria-label="View all history"
className="history-view-all-btn"
@@ -140,13 +281,17 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
)}
</div>
{
<div className="px-4">
{taskHistory.filter((item) => item.ts && item.task).length > 0 ? (
taskHistory
.filter((item) => item.ts && item.task)
.slice(0, 3)
.map((item) => (
<div className="px-4">
{displayTasks.length > 0 ? (
workspaceGroups.map((group) => (
<div key={group.label}>
{hasMultipleWorkspaces && (
<div className="history-workspace-divider">
<span className="codicon codicon-folder" style={{ fontSize: "0.9em" }} />
{group.label}
</div>
)}
{group.tasks.map((item) => (
<div className="history-preview-item" key={item.id} onClick={() => handleHistorySelect(item.id)}>
<div className="history-task-content">
{item.isFavorited && (
@@ -168,20 +313,21 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
)}
</div>
</div>
))
) : (
<div
style={{
textAlign: "center",
color: "var(--vscode-descriptionForeground)",
fontSize: "var(--vscode-font-size)",
padding: "10px 0",
}}>
No recent tasks
))}
</div>
)}
</div>
}
))
) : (
<div
style={{
textAlign: "center",
color: "var(--vscode-descriptionForeground)",
fontSize: "var(--vscode-font-size)",
padding: "10px 0",
}}>
{filter === "workspace" ? "No tasks in this workspace" : "No recent tasks"}
</div>
)}
</div>
</div>
)
}
@@ -1,5 +1,5 @@
import { BooleanRequest, EmptyRequest, StringArrayRequest } from "@shared/proto/cline/common"
import { GetTaskHistoryRequest, TaskFavoriteRequest } from "@shared/proto/cline/task"
import { GetTaskHistoryRequest, TaskFavoriteRequest, type TaskItem } from "@shared/proto/cline/task"
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import Fuse, { FuseResult } from "fuse.js"
import { FunnelIcon } from "lucide-react"
@@ -50,7 +50,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
const [pendingFavoriteToggles, setPendingFavoriteToggles] = useState<Record<string, boolean>>({})
// Load filtered task history with gRPC
const [tasks, setTasks] = useState<any[]>([])
const [tasks, setTasks] = useState<TaskItem[]>([])
// Load and refresh task history
const loadTaskHistory = useCallback(async () => {
@@ -157,9 +157,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
setSelectedItems((prev) => {
if (checked) {
return [...prev, itemId]
} else {
return prev.filter((id) => id !== itemId)
}
return prev.filter((id) => id !== itemId)
})
}, [])
@@ -233,20 +232,58 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
// Group tasks into "Today" and "Older" (only for date-based sorts)
const { groupedTasks, groupCounts, groupLabels } = useMemo(() => {
const isDateSort = sortOption === "newest" || sortOption === "oldest"
const workspaceTasks: TaskItem[] = []
const otherWorkspaceTasks: TaskItem[] = []
taskHistorySearchResults.forEach((task) => {
if (task.isCurrentWorkspace) {
workspaceTasks.push(task)
} else {
otherWorkspaceTasks.push(task)
}
})
const groups: { tasks: TaskItem[]; label: string }[] = []
// Derive workspace label from first task's workspaceName
const currentWorkspaceLabel =
workspaceTasks.length > 0 && workspaceTasks[0].workspaceName ? workspaceTasks[0].workspaceName : "This Workspace"
// When workspace filter is active, all tasks are already workspace-only from backend
// When not active, show workspace tasks grouped at top
if (showCurrentWorkspaceOnly) {
// All tasks are workspace tasks, treat them as the full set (no special grouping needed)
// They'll be date-grouped below
} else if (workspaceTasks.length > 0) {
groups.push({ tasks: workspaceTasks, label: currentWorkspaceLabel })
}
if (!isDateSort) {
// No grouping for non-date sorts
if (otherWorkspaceTasks.length > 0) {
groups.push({ tasks: otherWorkspaceTasks, label: workspaceTasks.length > 0 ? "Other Workspaces" : "All Tasks" })
}
if (groups.length === 0) {
return {
groupedTasks: taskHistorySearchResults,
groupCounts: [taskHistorySearchResults.length],
groupLabels: [] as string[],
}
}
return {
groupedTasks: taskHistorySearchResults,
groupCounts: [taskHistorySearchResults.length],
groupLabels: [] as string[],
groupedTasks: groups.flatMap((g) => g.tasks),
groupCounts: groups.map((g) => g.tasks.length),
groupLabels: groups.map((g) => g.label),
}
}
const todayTasks: any[] = []
const olderTasks: any[] = []
// Date-group the remaining tasks (non-workspace, or all tasks if workspace filter is active)
const tasksToDateGroup = showCurrentWorkspaceOnly ? taskHistorySearchResults : otherWorkspaceTasks
const todayTasks: TaskItem[] = []
const olderTasks: TaskItem[] = []
taskHistorySearchResults.forEach((task) => {
tasksToDateGroup.forEach((task) => {
if (isToday(task.ts)) {
todayTasks.push(task)
} else {
@@ -254,12 +291,21 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
}
})
const groups: { tasks: any[]; label: string }[] = []
if (todayTasks.length > 0) {
groups.push({ tasks: todayTasks, label: "Today" })
const hasWorkspaceGroup = !showCurrentWorkspaceOnly && workspaceTasks.length > 0
groups.push({ tasks: todayTasks, label: hasWorkspaceGroup ? "Today (Other Workspaces)" : "Today" })
}
if (olderTasks.length > 0) {
groups.push({ tasks: olderTasks, label: "Older" })
const hasWorkspaceGroup = !showCurrentWorkspaceOnly && workspaceTasks.length > 0
groups.push({ tasks: olderTasks, label: hasWorkspaceGroup ? "Older (Other Workspaces)" : "Older" })
}
if (groups.length === 0) {
return {
groupedTasks: taskHistorySearchResults,
groupCounts: [taskHistorySearchResults.length],
groupLabels: [] as string[],
}
}
return {
@@ -267,7 +313,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
groupCounts: groups.map((g) => g.tasks.length),
groupLabels: groups.map((g) => g.label),
}
}, [taskHistorySearchResults, sortOption])
}, [taskHistorySearchResults, sortOption, showCurrentWorkspaceOnly])
// Calculate total size of selected items
const selectedItemsSize = useMemo(() => {
@@ -275,8 +321,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
return 0
}
return taskHistory.filter((item) => selectedItems.includes(item.id)).reduce((total, item) => total + (item.size || 0), 0)
}, [selectedItems, taskHistory])
return tasks.filter((item) => selectedItems.includes(item.id)).reduce((total, item) => total + (item.size || 0), 0)
}, [selectedItems, tasks])
const handleBatchHistorySelect = useCallback(
(selectAll: boolean) => {
@@ -402,6 +448,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
groupCounts={groupCounts}
itemContent={(index) => {
const item = groupedTasks[index]
if (!item) return null
return (
<HistoryViewItem
handleDeleteHistoryItem={handleDeleteHistoryItem}
@@ -460,7 +507,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
}
// https://gist.github.com/evenfrost/1ba123656ded32fb7a0cd4651efd4db0
export const highlight = (fuseSearchResult: FuseResult<any>[], highlightClassName: string = "history-item-highlight") => {
export const highlight = (fuseSearchResult: FuseResult<any>[], highlightClassName = "history-item-highlight") => {
const set = (obj: Record<string, any>, path: string, value: any) => {
const pathValue = path.split(".")
let i: number
@@ -1,5 +1,5 @@
import { HistoryItem } from "@shared/HistoryItem"
import { StringRequest } from "@shared/proto/cline/common"
import { type TaskItem } from "@shared/proto/cline/task"
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import {
ArrowDownIcon,
@@ -19,7 +19,7 @@ import { TaskServiceClient } from "@/services/grpc-client"
import { formatLargeNumber, formatSize } from "@/utils/format"
type HistoryViewItemProps = {
item: HistoryItem
item: TaskItem
index: number
selectedItems: string[]
pendingFavoriteToggles: Record<string, boolean>
@@ -95,8 +95,10 @@ const HistoryViewItem = ({
handleShowTaskWithId(item.id)
}}>
<div className="flex items-center gap-2">
<div className="line-clamp-1 overflow-hidden break-words whitespace-pre-wrap flex-1 min-w-0">
<span className="ph-no-capture">{item.task}</span>
<div className="flex-1 min-w-0">
<div className="line-clamp-1 overflow-hidden break-words whitespace-pre-wrap">
<span className="ph-no-capture">{item.task}</span>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<Button
@@ -138,7 +140,10 @@ const HistoryViewItem = ({
}}
variant="icon">
<div className="flex items-center justify-between w-full">
<div className="text-description text-xs uppercase">{formatDate(item.ts)}</div>
<div className="text-description text-xs uppercase">
{item.workspaceName && <span className="font-medium">{item.workspaceName} · </span>}
{formatDate(item.ts)}
</div>
<div className="self-end flex items-center text-xs">
<span className="text-description">${item.totalCost?.toFixed(4) ?? 0}</span>
{expanded ? (