Compare commits

...

3 Commits

Author SHA1 Message Date
Jose R. Perez 3e261f2f47 feat: small color change 2026-01-29 17:11:16 -08:00
Jose R. Perez 5f050c5353 feat: small styling adjustment 2026-01-29 17:09:15 -08:00
Jose R. Perez 2516424020 feat: added pin, custom name abilities to task 2026-01-29 17:00:28 -08:00
12 changed files with 745 additions and 150 deletions
+23
View File
@@ -28,6 +28,8 @@ service TaskService {
rpc exportTaskWithId(StringRequest) returns (Empty);
// Toggles the favorite status of a task
rpc toggleTaskFavorite(TaskFavoriteRequest) returns (Empty);
// Toggles the pin status of a task
rpc toggleTaskPin(TaskPinRequest) returns (Empty);
// Gets filtered task history
rpc getTaskHistory(GetTaskHistoryRequest) returns (TaskHistoryArray);
// Sends a response to a previous ask operation
@@ -42,6 +44,8 @@ service TaskService {
rpc deleteAllTaskHistory(EmptyRequest) returns (DeleteAllTaskHistoryCount);
// Explains changes with AI and adds inline comments to the diff view
rpc explainChanges(ExplainChangesRequest) returns (Empty);
// Updates the custom name for a task
rpc updateTaskName(UpdateTaskNameRequest) returns (Empty);
}
// Request message for creating a new task
@@ -60,6 +64,21 @@ message TaskFavoriteRequest {
bool is_favorited = 3;
}
// Request message for toggling task pin status
message TaskPinRequest {
Metadata metadata = 1;
string task_id = 2;
bool is_pinned = 3;
}
// Request message for updating task custom name
message UpdateTaskNameRequest {
Metadata metadata = 1;
string task_id = 2;
string custom_name = 3;
string custom_name_color = 4;
}
// Response for task details
message TaskResponse {
string id = 1;
@@ -73,6 +92,7 @@ message TaskResponse {
int32 cache_writes = 9;
int32 cache_reads = 10;
string model_id = 11;
bool is_pinned = 12;
}
// Request for getting task history with filtering
@@ -103,6 +123,9 @@ message TaskItem {
int32 cache_writes = 9;
int32 cache_reads = 10;
string model_id = 11;
bool is_pinned = 12;
string custom_name = 13;
string custom_name_color = 14;
}
// Request for ask response operation
+13 -1
View File
@@ -1007,11 +1007,23 @@ export class Controller {
const history = this.stateManager.getGlobalStateKey("taskHistory")
const existingItemIndex = history.findIndex((h) => h.id === item.id)
if (existingItemIndex !== -1) {
history[existingItemIndex] = item
// Preserve custom fields from existing item when updating
const existingItem = history[existingItemIndex]
history[existingItemIndex] = {
...item,
// Only preserve these fields if they exist in the existing item and are not explicitly set in the new item
customName: item.customName ?? existingItem.customName,
customNameColor: item.customNameColor ?? existingItem.customNameColor,
isPinned: item.isPinned ?? existingItem.isPinned,
}
} else {
history.push(item)
}
this.stateManager.setGlobalState("taskHistory", history)
// Force immediate write to disk to survive hot reloads
await this.stateManager.flushPendingState()
return history
}
+17 -3
View File
@@ -66,9 +66,15 @@ export async function getTaskHistory(controller: Controller, request: GetTaskHis
// Calculate total count before sorting
const totalCount = filteredTasks.length
// Apply sorting
// Apply sorting - pinned tasks always come first
if (sortBy) {
filteredTasks.sort((a, b) => {
// First, sort by pinned status
if (a.isPinned !== b.isPinned) {
return a.isPinned ? -1 : 1
}
// Then apply the selected sort
switch (sortBy) {
case "oldest":
return a.ts - b.ts
@@ -88,8 +94,13 @@ export async function getTaskHistory(controller: Controller, request: GetTaskHis
}
})
} else {
// Default sort by newest
filteredTasks.sort((a, b) => b.ts - a.ts)
// Default sort by newest, but pinned first
filteredTasks.sort((a, b) => {
if (a.isPinned !== b.isPinned) {
return a.isPinned ? -1 : 1
}
return b.ts - a.ts
})
}
// Map to response format
@@ -98,6 +109,7 @@ export async function getTaskHistory(controller: Controller, request: GetTaskHis
task: item.task,
ts: item.ts,
isFavorited: item.isFavorited || false,
isPinned: item.isPinned || false,
size: item.size || 0,
totalCost: item.totalCost || 0,
tokensIn: item.tokensIn || 0,
@@ -105,6 +117,8 @@ export async function getTaskHistory(controller: Controller, request: GetTaskHis
cacheWrites: item.cacheWrites || 0,
cacheReads: item.cacheReads || 0,
modelId: item.modelId || "",
customName: item.customName || "",
customNameColor: item.customNameColor || "",
}))
return TaskHistoryArray.create({
@@ -30,6 +30,9 @@ export async function toggleTaskFavorite(controller: Controller, request: TaskFa
// Update global state and wait for it to complete
try {
controller.stateManager.setGlobalState("taskHistory", updatedHistory)
// Force immediate write to disk to survive hot reloads
await controller.stateManager.flushPendingState()
} catch (stateErr) {
Logger.error("Error updating global state:", stateErr)
}
+55
View File
@@ -0,0 +1,55 @@
import { Empty } from "@shared/proto/cline/common"
import { TaskPinRequest } from "@shared/proto/cline/task"
import { Logger } from "@/shared/services/Logger"
import { Controller } from "../"
export async function toggleTaskPin(controller: Controller, request: TaskPinRequest): Promise<Empty> {
if (!request.taskId || request.isPinned === undefined) {
const errorMsg = `[toggleTaskPin] Invalid request: taskId or isPinned missing`
Logger.error(errorMsg)
return Empty.create({})
}
try {
// Update in-memory state only
try {
const history = controller.stateManager.getGlobalStateKey("taskHistory")
const taskIndex = history.findIndex((item) => item.id === request.taskId)
if (taskIndex === -1) {
Logger.log(`[toggleTaskPin] Task not found in history array!`)
} else {
// Create a new array instead of modifying in place to ensure state change
const updatedHistory = [...history]
updatedHistory[taskIndex] = {
...updatedHistory[taskIndex],
isPinned: request.isPinned,
}
// Update global state and wait for it to complete
try {
controller.stateManager.setGlobalState("taskHistory", updatedHistory)
// Force immediate write to disk to survive hot reloads
await controller.stateManager.flushPendingState()
} catch (stateErr) {
Logger.error("Error updating global state:", stateErr)
}
}
} catch (historyErr) {
Logger.error("Error processing task history:", historyErr)
}
// Post to webview
try {
await controller.postStateToWebview()
} catch (webviewErr) {
Logger.error("Error posting to webview:", webviewErr)
}
} catch (error) {
Logger.error("Error in toggleTaskPin:", error)
}
return Empty.create({})
}
@@ -0,0 +1,74 @@
import { Empty } from "@shared/proto/cline/common"
import { UpdateTaskNameRequest } from "@shared/proto/cline/task"
import { Logger } from "@/shared/services/Logger"
import { Controller } from "../"
export async function updateTaskName(controller: Controller, request: UpdateTaskNameRequest): Promise<Empty> {
if (!request.taskId) {
const errorMsg = `[updateTaskName] Invalid request: taskId missing`
Logger.error(errorMsg)
return Empty.create({})
}
Logger.log(`[updateTaskName] Received request for task ${request.taskId}:`, {
customName: request.customName,
customNameColor: request.customNameColor,
})
try {
// Update in-memory state only
try {
const history = controller.stateManager.getGlobalStateKey("taskHistory")
const taskIndex = history.findIndex((item) => item.id === request.taskId)
if (taskIndex === -1) {
Logger.log(`[updateTaskName] Task not found in history array!`)
} else {
const oldTask = history[taskIndex]
Logger.log(`[updateTaskName] Current task state:`, {
customName: oldTask.customName,
customNameColor: oldTask.customNameColor,
})
// Create a new array instead of modifying in place to ensure state change
const updatedHistory = [...history]
updatedHistory[taskIndex] = {
...updatedHistory[taskIndex],
customName: request.customName || undefined,
customNameColor: request.customNameColor || undefined,
}
Logger.log(`[updateTaskName] Updated task state:`, {
customName: updatedHistory[taskIndex].customName,
customNameColor: updatedHistory[taskIndex].customNameColor,
})
// Update global state and wait for it to complete
try {
controller.stateManager.setGlobalState("taskHistory", updatedHistory)
Logger.log(`[updateTaskName] Successfully saved to global state`)
// Force immediate write to disk to survive hot reloads
await controller.stateManager.flushPendingState()
Logger.log(`[updateTaskName] Successfully flushed to disk`)
} catch (stateErr) {
Logger.error("Error updating global state:", stateErr)
}
}
} catch (historyErr) {
Logger.error("Error processing task history:", historyErr)
}
// Post to webview
try {
await controller.postStateToWebview()
} catch (webviewErr) {
Logger.error("Error posting to webview:", webviewErr)
}
} catch (error) {
Logger.error("Error in updateTaskName:", error)
}
return Empty.create({})
}
+5 -1
View File
@@ -80,7 +80,7 @@ export class MessageStateHandler {
* This is used by methods that already hold the stateMutex lock
* Should NOT be called directly - use saveClineMessagesAndUpdateHistory() instead
*/
private async saveClineMessagesAndUpdateHistoryInternal(): Promise<void> {
private async saveClineMessagesAndUpdateHistoryInternal(existingHistoryItem?: HistoryItem): Promise<void> {
try {
await saveClineMessages(this.taskId, this.clineMessages)
@@ -122,6 +122,10 @@ export class MessageStateHandler {
isFavorited: this.taskIsFavorited,
checkpointManagerErrorMessage: this.taskState.checkpointManagerErrorMessage,
modelId: lastModelInfo?.modelInfo?.modelId,
// Preserve custom fields from existing history item
customName: existingHistoryItem?.customName,
customNameColor: existingHistoryItem?.customNameColor,
isPinned: existingHistoryItem?.isPinned,
})
} catch (error) {
Logger.error("Failed to save cline messages:", error)
+3
View File
@@ -14,7 +14,10 @@ export type HistoryItem = {
cwdOnTaskInitialization?: string
conversationHistoryDeletedRange?: [number, number]
isFavorited?: boolean
isPinned?: boolean
checkpointManagerErrorMessage?: string
modelId?: string
customName?: string
customNameColor?: string
}
@@ -161,7 +161,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<div className="flex items-center select-none grow min-w-0 gap-1 justify-between">
{!isTaskExpanded && (
<div className="whitespace-nowrap overflow-hidden text-ellipsis grow min-w-0">
<span className="ph-no-capture text-base">{highlightedText}</span>
{currentTaskItem?.customName ? (
<span className="ph-no-capture text-base font-medium">{currentTaskItem.customName}</span>
) : (
<span className="ph-no-capture text-base">{highlightedText}</span>
)}
</div>
)}
</div>
@@ -39,6 +39,9 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
align-items: flex-start;
gap: 12px;
}
.history-preview-item.pinned {
border-bottom: 3px solid var(--vscode-button-background);
}
.history-preview-item:hover {
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 100%, transparent);
pointer-events: auto;
@@ -100,6 +103,23 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
.history-view-all-btn:hover {
color: var(--vscode-foreground);
}
.history-pin-badge {
position: absolute;
bottom: 0;
left: 8px;
background-color: var(--vscode-button-background);
border-radius: 2px 2px 0 0;
padding: 2px 1px 0px 1px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.history-pin-badge .codicon {
color: var(--vscode-button-foreground);
font-size: 12px;
transform: rotate(-90deg);
}
`}
</style>
@@ -145,27 +165,82 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
{taskHistory.filter((item) => item.ts && item.task).length > 0 ? (
taskHistory
.filter((item) => item.ts && item.task)
.slice(0, 3)
.sort((a, b) => {
// Pinned tasks first
if (a.isPinned !== b.isPinned) {
return a.isPinned ? -1 : 1
}
// Then by timestamp (newest first)
return b.ts - a.ts
})
.slice(0, 5)
.map((item) => (
<div className="history-preview-item" key={item.id} onClick={() => handleHistorySelect(item.id)}>
<div
className={`history-preview-item ${item.isPinned ? "pinned" : ""}`}
key={item.id}
onClick={() => handleHistorySelect(item.id)}>
{item.isPinned && (
<div
aria-label="Pinned"
className="history-pin-badge"
onClick={async (e) => {
e.stopPropagation()
// Toggle pin status
try {
const { TaskPinRequest } = await import("@shared/proto/cline/task")
await TaskServiceClient.toggleTaskPin(
TaskPinRequest.create({
taskId: item.id,
isPinned: false,
}),
)
} catch (error) {
console.error("Error unpinning task:", error)
}
}}
style={{
cursor: "pointer",
}}>
<span className="codicon codicon-pin" />
</div>
)}
<div className="history-task-content">
{item.isFavorited && (
<span
aria-label="Favorited"
className="codicon codicon-star-full"
onClick={async (e) => {
e.stopPropagation()
// Toggle favorite status
try {
const { TaskFavoriteRequest } = await import("@shared/proto/cline/task")
await TaskServiceClient.toggleTaskFavorite(
TaskFavoriteRequest.create({
taskId: item.id,
isFavorited: false,
}),
)
} catch (error) {
console.error("Error unfavoriting task:", error)
}
}}
style={{
color: "var(--vscode-button-background)",
flexShrink: 0,
cursor: "pointer",
marginLeft: "-4px",
}}
/>
)}
<div className="history-task-description ph-no-capture">{item.task}</div>
<div className="history-task-description ph-no-capture">
{item.customName || item.task}
</div>
</div>
<div className="history-meta-stack">
<span className="history-date">{formatDate(item.ts)}</span>
{item.totalCost != null && (
<span className="history-cost-chip">${item.totalCost.toFixed(2)}</span>
)}
<span className="history-date">{formatDate(item.ts)}</span>
</div>
</div>
))
@@ -49,6 +49,9 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
// Keep track of pending favorite toggle operations
const [pendingFavoriteToggles, setPendingFavoriteToggles] = useState<Record<string, boolean>>({})
// Keep track of pending pin toggle operations
const [pendingPinToggles, setPendingPinToggles] = useState<Record<string, boolean>>({})
// Load filtered task history with gRPC
const [tasks, setTasks] = useState<any[]>([])
@@ -67,7 +70,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
} catch (error) {
console.error("Error loading task history:", error)
}
}, [showFavoritesOnly, showCurrentWorkspaceOnly, searchQuery, sortOption, taskHistory])
}, [showFavoritesOnly, showCurrentWorkspaceOnly, searchQuery, sortOption])
// Load when filters change
useEffect(() => {
@@ -92,10 +95,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
}),
)
// Refresh if either filter is active to ensure proper combined filtering
if (showFavoritesOnly || showCurrentWorkspaceOnly) {
loadTaskHistory()
}
// Always refresh to show the updated favorite state immediately
loadTaskHistory()
} catch (err) {
console.error(`[FAVORITE_TOGGLE_UI] Error for task ${taskId}:`, err)
// Revert optimistic update
@@ -115,7 +116,47 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
}, 1000)
}
},
[showFavoritesOnly, loadTaskHistory],
[loadTaskHistory],
)
const togglePin = useCallback(
async (taskId: string, currentValue: boolean) => {
// Optimistic UI update
setPendingPinToggles((prev) => ({ ...prev, [taskId]: !currentValue }))
try {
// Import TaskPinRequest locally to avoid formatter issues
const { TaskPinRequest } = await import("@shared/proto/cline/task")
await TaskServiceClient.toggleTaskPin(
TaskPinRequest.create({
taskId,
isPinned: !currentValue,
}),
)
// Always refresh to ensure proper sorting with pinned items
loadTaskHistory()
} catch (err) {
console.error(`[PIN_TOGGLE_UI] Error for task ${taskId}:`, err)
// Revert optimistic update
setPendingPinToggles((prev) => {
const updated = { ...prev }
delete updated[taskId]
return updated
})
} finally {
// Clean up pending state after 1 second
setTimeout(() => {
setPendingPinToggles((prev) => {
const updated = { ...prev }
delete updated[taskId]
return updated
})
}, 1000)
}
},
[loadTaskHistory],
)
// Use the onRelinquishControl hook instead of message event
@@ -254,6 +295,21 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
}
})
// Sort pinned tasks to top of each group
const sortWithPinnedFirst = (tasks: any[]) => {
return tasks.sort((a, b) => {
// Pinned tasks first
if (a.isPinned !== b.isPinned) {
return a.isPinned ? -1 : 1
}
// Then by timestamp (already sorted by taskHistorySearchResults)
return 0
})
}
sortWithPinnedFirst(todayTasks)
sortWithPinnedFirst(olderTasks)
const groups: { tasks: any[]; label: string }[] = []
if (todayTasks.length > 0) {
groups.push({ tasks: todayTasks, label: "Today" })
@@ -417,9 +473,12 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
handleHistorySelect={handleHistorySelect}
index={index}
item={item}
onTaskUpdated={loadTaskHistory}
pendingFavoriteToggles={pendingFavoriteToggles}
pendingPinToggles={pendingPinToggles}
selectedItems={selectedItems}
toggleFavorite={toggleFavorite}
togglePin={togglePin}
/>
)
}}
@@ -450,7 +509,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
) : (
<Button
aria-label="Delete all history"
className="w-full"
className="w-full border border-transparent hover:!border-red-500"
disabled={deleteAllDisabled || taskHistory.length === 0}
onClick={() => {
setDeleteAllDisabled(true)
@@ -459,7 +518,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
.catch((error) => console.error("Error deleting task history:", error))
.finally(() => setDeleteAllDisabled(false))
}}
variant="danger">
variant="secondary">
Delete All History{totalTasksSize !== null ? ` (${formatSize(totalTasksSize)})` : ""}
</Button>
)}
@@ -1,16 +1,18 @@
import { HistoryItem } from "@shared/HistoryItem"
import { StringRequest } from "@shared/proto/cline/common"
import { UpdateTaskNameRequest } from "@shared/proto/cline/task"
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import {
ArrowDownIcon,
ArrowLeftIcon,
ArrowRightIcon,
ArrowUpIcon,
ChevronsDownUpIcon,
ChevronsUpDownIcon,
CheckIcon,
ChevronRightIcon,
CopyIcon,
DownloadIcon,
Pin,
StarIcon,
TrashIcon,
} from "lucide-react"
import { memo, useCallback, useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
@@ -23,33 +25,147 @@ type HistoryViewItemProps = {
index: number
selectedItems: string[]
pendingFavoriteToggles: Record<string, boolean>
pendingPinToggles: Record<string, boolean>
handleDeleteHistoryItem: (id: string) => void
toggleFavorite: (id: string, isCurrentlyFavorited: boolean) => void
togglePin: (id: string, isCurrentlyPinned: boolean) => void
handleHistorySelect: (itemId: string, checked: boolean) => void
onTaskUpdated: () => void
}
const HistoryViewItem = ({
item,
pendingFavoriteToggles,
pendingPinToggles,
handleDeleteHistoryItem,
toggleFavorite,
togglePin,
handleHistorySelect,
selectedItems,
onTaskUpdated,
}: HistoryViewItemProps) => {
const [expanded, setExpanded] = useState(false)
const [isEditingName, setIsEditingName] = useState(false)
const [editNameValue, setEditNameValue] = useState(item.customName || "")
const [copied, setCopied] = useState(false)
const [showColorPicker, setShowColorPicker] = useState(false)
const presetColors = [
{ name: "Yellow", value: "#f0c674" },
{ name: "Blue", value: "#81a2be" },
{ name: "Green", value: "#b5bd68" },
{ name: "Orange", value: "#de935f" },
{ name: "Purple", value: "#b294bb" },
{ name: "Red", value: "#cc6666" },
]
// Debug logging to see what color the item actually has
console.log(`[HistoryViewItem] Rendering item ${item.id}:`, {
customName: item.customName,
customNameColor: item.customNameColor,
})
const isFavoritedItem = useMemo(
() => pendingFavoriteToggles[item.id] ?? item.isFavorited,
[item.id, item.isFavorited, pendingFavoriteToggles],
)
const isPinnedItem = useMemo(() => pendingPinToggles[item.id] ?? item.isPinned, [item.id, item.isPinned, pendingPinToggles])
const handleSaveCustomName = useCallback(
async (colorOverride?: string) => {
const trimmedValue = editNameValue.trim()
if (trimmedValue === item.customName || (trimmedValue === "" && !item.customName)) {
setIsEditingName(false)
setShowColorPicker(false)
return
}
try {
await TaskServiceClient.updateTaskName(
UpdateTaskNameRequest.create({
taskId: item.id,
customName: trimmedValue,
customNameColor: colorOverride !== undefined ? colorOverride : item.customNameColor,
}),
)
setIsEditingName(false)
setShowColorPicker(false)
// Reload the history view to show the updated name
onTaskUpdated()
} catch (error) {
console.error("Error updating task name:", error)
}
},
[editNameValue, item.id, item.customName, item.customNameColor, onTaskUpdated],
)
const handleColorSelect = useCallback(
async (color: string) => {
try {
// Get the current name being edited, or use the existing custom name
const nameToSave = editNameValue.trim() || item.customName || ""
console.log(`[HistoryViewItem] Sending color update:`, {
taskId: item.id,
customName: nameToSave,
customNameColor: color,
currentItemColor: item.customNameColor,
})
await TaskServiceClient.updateTaskName(
UpdateTaskNameRequest.create({
taskId: item.id,
customName: nameToSave,
customNameColor: color,
}),
)
console.log(`[HistoryViewItem] Color update sent successfully`)
// Reload the history view to show the updated color
onTaskUpdated()
// Don't close the picker - let user continue editing
} catch (error) {
console.error("Error updating task color:", error)
}
},
[item.id, item.customName, editNameValue, onTaskUpdated],
)
const handleCancelEdit = useCallback(() => {
setEditNameValue(item.customName || "")
setIsEditingName(false)
}, [item.customName])
const handleCopyTask = useCallback(() => {
navigator.clipboard.writeText(item.task).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}, [item.task])
const handleShowTaskWithId = useCallback((id: string) => {
TaskServiceClient.showTaskWithId(StringRequest.create({ value: id })).catch((error) =>
console.error("Error showing task:", error),
)
}, [])
const formatDate = useCallback((timestamp: number) => {
const formatDateShort = useCallback((timestamp: number) => {
const date = new Date(timestamp)
return {
date: date.toLocaleString("en-US", {
month: "short",
day: "numeric",
}),
time: date.toLocaleString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
}),
}
}, [])
const formatDateLong = useCallback((timestamp: number) => {
const date = new Date(timestamp)
const today = new Date()
const isToday = today.toDateString() === date.toDateString()
@@ -76,151 +192,304 @@ const HistoryViewItem = ({
}, [])
return (
<div className="history-item cursor-pointer flex group mb-1 hover:bg-list-hover border-b border-accent/10" key={item.id}>
<VSCodeCheckbox
checked={selectedItems.includes(item.id)}
className="pl-3 pr-1 py-auto self-start mt-3"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const checked = (e.target as HTMLInputElement).checked
handleHistorySelect(item.id, checked)
}}
/>
<div
className="group mb-2 rounded mx-3"
key={item.id}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 100%, transparent)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor =
"color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 65%, transparent)"
}}
style={{
backgroundColor: "color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 65%, transparent)",
transition: "background-color 0.2s",
}}>
{/* Header bar with icons */}
<div
className="flex flex-col gap-2 py-2 pl-2 pr-3 relative flex-grow w-full"
className="flex items-center justify-between gap-2 px-3 pt-0 pb-0 rounded-t"
style={{
backgroundColor: "#212121",
}}>
{/* Left side: Custom name field */}
<div className="flex-1 min-w-0 py-1">
{isEditingName ? (
<div className="relative">
<input
autoFocus
className="w-full bg-transparent border-none outline-none text-[10px] text-[var(--vscode-input-foreground)] px-1"
maxLength={75}
onBlur={() => {
// Save on blur (clicking outside, tabbing away, etc.)
handleSaveCustomName()
setShowColorPicker(false)
}}
onChange={(e) => setEditNameValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
onFocus={() => setShowColorPicker(true)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSaveCustomName()
setShowColorPicker(false)
} else if (e.key === "Escape") {
handleCancelEdit()
setShowColorPicker(false)
}
e.stopPropagation()
}}
placeholder="Click to name..."
style={{
backgroundColor: "var(--vscode-input-background)",
borderBottom: "1px solid var(--vscode-input-border)",
}}
type="text"
value={editNameValue}
/>
{showColorPicker && (
<div
className="absolute top-full left-0 mt-1 p-2 rounded shadow-lg z-50"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.preventDefault()}
style={{
backgroundColor: "var(--vscode-dropdown-background)",
border: "1px solid var(--vscode-dropdown-border)",
}}>
<div className="flex gap-1">
{presetColors.map((color) => (
<button
aria-label={`${color.name} color`}
className="w-6 h-6 rounded cursor-pointer border-2 hover:scale-110 transition-transform"
key={color.value}
onClick={() => handleColorSelect(color.value)}
style={{
backgroundColor: color.value,
borderColor:
item.customNameColor === color.value
? "var(--vscode-focusBorder)"
: "transparent",
}}
type="button"
/>
))}
</div>
</div>
)}
</div>
) : (
<button
className="w-full text-left text-[10px] px-1 py-0.5 rounded hover:bg-[var(--vscode-input-background)] transition-colors truncate"
onClick={(e) => {
e.stopPropagation()
setIsEditingName(true)
setEditNameValue(item.customName || "")
setShowColorPicker(true)
}}
style={{
color: item.customName
? item.customNameColor || "#f0c674"
: "var(--vscode-descriptionForeground)",
fontStyle: item.customName ? "normal" : "italic",
opacity: item.customName ? 1 : 0.7,
}}
type="button">
{item.customName || "Click to name..."}
</button>
)}
</div>
<Button
aria-label={isPinnedItem ? "Unpin task" : "Pin task"}
className={cn("p-0 transition-opacity", {
"opacity-0 group-hover:opacity-100": !isPinnedItem,
})}
disabled={pendingPinToggles[item.id] !== undefined}
onClick={(e) => {
e.stopPropagation()
togglePin(item.id, isPinnedItem)
}}
variant="icon">
<Pin
className={cn("opacity-70", {
"text-button-background fill-button-background opacity-100": isPinnedItem,
})}
size={16}
style={{ transform: "rotate(45deg) scale(0.7)" }}
/>
</Button>
<Button
aria-label={isFavoritedItem ? "Remove from favorites" : "Add to favorites"}
className={cn("p-0 transition-opacity", {
"opacity-0 group-hover:opacity-100": !isFavoritedItem && !isPinnedItem,
})}
disabled={pendingFavoriteToggles[item.id] !== undefined}
onClick={(e) => {
e.stopPropagation()
toggleFavorite(item.id, isFavoritedItem)
}}
variant="icon">
<StarIcon
className={cn("opacity-70", {
"text-button-background fill-button-background opacity-100": isFavoritedItem,
})}
size={16}
style={{ transform: "scale(0.7)" }}
/>
</Button>
<Button
aria-label="Copy task text"
className="p-0"
onClick={(e) => {
e.stopPropagation()
handleCopyTask()
}}
variant="icon">
{copied ? (
<CheckIcon className="opacity-70" size={16} style={{ transform: "scale(0.7)" }} />
) : (
<CopyIcon className="opacity-70" size={16} style={{ transform: "scale(0.7)" }} />
)}
</Button>
<VSCodeCheckbox
checked={selectedItems.includes(item.id)}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const checked = (e.target as HTMLInputElement).checked
handleHistorySelect(item.id, checked)
}}
style={{ transform: "scale(0.7)" }}
/>
</div>
{/* Main content */}
<div
className="cursor-pointer px-3 pb-1 pt-2"
onClick={(e) => {
e.stopPropagation()
handleShowTaskWithId(item.id)
}}>
<div className="flex justify-between items-center">
<div className="line-clamp-1 overflow-hidden break-words whitespace-pre-wrap">
<span className="ph-no-capture">{item.task}</span>
</div>
<div className="flex gap-2">
<Button
aria-label="Delete"
className="p-0 opacity-0 group-hover:opacity-100 transition-opacity"
disabled={isFavoritedItem}
<div className="flex justify-between gap-3 min-h-[60px]">
{/* Left side: Task title and details link */}
<div className="flex flex-col justify-between flex-1 min-w-0">
<div className="text-sm font-normal mb-1 line-clamp-2 break-words" style={{ lineHeight: "1.2" }}>
<span className="ph-no-capture">{item.task}</span>
</div>
<button
className="text-[10px] text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)] flex items-center gap-1 p-0 bg-transparent border-none cursor-pointer self-start mb-1"
onClick={(e) => {
e.stopPropagation()
handleDeleteHistoryItem(item.id)
setExpanded(!expanded)
}}
variant="ghost">
<span className="flex items-center gap-1 text-xs">
<TrashIcon className="stroke-1" />
</span>
</Button>
<Button
aria-label={isFavoritedItem ? "Remove from favorites" : "Add to favorites"}
className="p-0"
disabled={pendingFavoriteToggles[item.id] !== undefined}
onClick={(e) => {
e.stopPropagation()
toggleFavorite(item.id, isFavoritedItem)
}}
variant="icon">
<StarIcon
className={cn("opacity-70", {
"text-button-background fill-button-background opacity-100": isFavoritedItem,
})}
type="button">
Task Details
<ChevronRightIcon
className="transition-transform"
size={10}
style={{
transform: expanded ? "rotate(90deg)" : "rotate(0deg)",
}}
/>
</Button>
</button>
</div>
{/* Right side: Cost and date */}
<div className="flex flex-col items-center gap-2 flex-shrink-0">
{/* Cost badge */}
{item.totalCost != null && (
<div
className="px-3 py-1 rounded-full text-xs font-medium"
style={{
backgroundColor: "var(--vscode-badge-background)",
color: "var(--vscode-badge-foreground)",
}}>
${item.totalCost.toFixed(2)}
</div>
)}
{/* Date */}
<div className="text-xs text-[var(--vscode-descriptionForeground)] mt-[2px]">
{formatDateShort(item.ts).date}
</div>
</div>
</div>
</div>
<Button
className="p-0"
onClick={(e) => {
e.stopPropagation()
setExpanded(!expanded)
}}
variant="icon">
<div className="flex items-center justify-between w-full">
<div className="text-description text-xs uppercase">{formatDate(item.ts)}</div>
<div className="self-end flex items-center text-xs">
<span className="text-description">${item.totalCost?.toFixed(4) ?? 0}</span>
{expanded ? (
<ChevronsDownUpIcon className="text-description" />
) : (
<ChevronsUpDownIcon className="text-description hidden opacity-0 group-hover:opacity-100 transition-opacity group-hover:block" />
)}
</div>
</div>
</Button>
{expanded && (
<Button
className="m-0 text-xs cursor-pointer p-2 bg-accent/10 w-full rounded-xs"
onClick={(e) => {
e.stopPropagation()
setExpanded(!expanded)
}}
variant="text">
<div className="flex flex-col gap-1 w-full text-xs">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-1 flex-wrap w-full">
<div className="flex justify-between items-center w-full gap-1 text-xs">
<span className="font-medium text-description">Tokens:</span>
<div className="flex items-center gap-1 text-description text-xs">
<span className="flex items-center gap-1 text-description">
<ArrowUpIcon className="text-description !size-1" />
{formatLargeNumber(item.tokensIn || 0)}
</span>
<span className="flex items-center gap-1 text-description">
<ArrowDownIcon className="text-description !size-1" />
{formatLargeNumber(item.tokensOut || 0)}
</span>
{item.cacheWrites
? item.cacheWrites > 0 && (
<span className="flex items-center gap-1 text-description">
<ArrowRightIcon className="text-description !size-1" />
{formatLargeNumber(item.cacheWrites)}
</span>
)
: null}
{item.cacheReads
? item.cacheReads > 0 && (
<span className="flex items-center gap-1 text-description">
<ArrowLeftIcon className="text-description !size-1" />
{formatLargeNumber(item.cacheReads)}
</span>
)
: null}
</div>
</div>
{/* Expanded details */}
{expanded && (
<div className="px-3 pb-3">
<div
className="p-3 rounded text-[10px]"
style={{
backgroundColor: "#212121",
}}>
<div className="flex flex-col gap-1 text-[10px]">
<div className="flex justify-between items-center text-[10px]">
<span className="font-medium text-[var(--vscode-descriptionForeground)] text-[10px]">Date:</span>
<span className="text-[var(--vscode-descriptionForeground)] text-[10px]">
{formatDateLong(item.ts)}
</span>
</div>
{item.modelId && (
<div className="flex justify-between items-center w-full gap-1 text-xs">
<span className="font-medium text-description">Model:</span>
<span className="text-description">{item.modelId}</span>
</div>
)}
<div className="flex justify-between items-center w-full gap-1 text-xs">
<span className="font-medium text-description">Size:</span>
<span className="items-center gap-2 flex text-description">
{formatSize(item.size)}
<Button
aria-label="Export"
className="m-0 p-0"
onClick={(e) => {
e.stopPropagation()
TaskServiceClient.exportTaskWithId(
StringRequest.create({ value: item.id }),
).catch((err) => console.error("Failed to export task:", err))
}}
variant="ghost">
<DownloadIcon />
</Button>
<div className="flex justify-between items-center text-[10px]">
<span className="font-medium text-[var(--vscode-descriptionForeground)] text-[10px]">
Tokens:
</span>
<div className="flex items-center gap-2 text-[var(--vscode-descriptionForeground)] text-[10px]">
<span className="flex items-center gap-1">
<ArrowUpIcon size={12} />
{formatLargeNumber(item.tokensIn || 0)}
</span>
<span className="flex items-center gap-1">
<ArrowDownIcon size={12} />
{formatLargeNumber(item.tokensOut || 0)}
</span>
{item.cacheWrites && item.cacheWrites > 0 && (
<span className="flex items-center gap-1">
<ArrowRightIcon size={12} />
{formatLargeNumber(item.cacheWrites)}
</span>
</div>
)}
{item.cacheReads && item.cacheReads > 0 && (
<span className="flex items-center gap-1">
<ArrowLeftIcon size={12} />
{formatLargeNumber(item.cacheReads)}
</span>
)}
</div>
</div>
{item.modelId && (
<div className="flex justify-between items-center text-[10px]">
<span className="font-medium text-[var(--vscode-descriptionForeground)] text-[10px]">
Model:
</span>
<span className="text-[var(--vscode-descriptionForeground)] text-[10px]">{item.modelId}</span>
</div>
)}
<div className="flex justify-between items-center text-[10px]">
<span className="font-medium text-[var(--vscode-descriptionForeground)] text-[10px]">Size:</span>
<span className="flex items-center gap-2 text-[var(--vscode-descriptionForeground)] text-[10px]">
{formatSize(item.size)}
<Button
aria-label="Export"
className="m-0 p-0"
onClick={(e) => {
e.stopPropagation()
TaskServiceClient.exportTaskWithId(StringRequest.create({ value: item.id })).catch(
(err) => console.error("Failed to export task:", err),
)
}}
variant="ghost">
<DownloadIcon size={14} />
</Button>
</span>
</div>
</div>
</Button>
)}
</div>
</div>
</div>
)}
</div>
)
}