mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat: add editable titles to task history
This commit is contained in:
parent
0f08867656
commit
1524d66ee4
12 changed files with 448 additions and 22 deletions
|
|
@ -10,6 +10,7 @@ export const historyItemSchema = z.object({
|
|||
parentTaskId: z.string().optional(),
|
||||
number: z.number(),
|
||||
ts: z.number(),
|
||||
title: z.string().optional(),
|
||||
task: z.string(),
|
||||
tokensIn: z.number(),
|
||||
tokensOut: z.number(),
|
||||
|
|
|
|||
|
|
@ -389,6 +389,7 @@ export interface WebviewMessage {
|
|||
| "importSettings"
|
||||
| "exportSettings"
|
||||
| "resetState"
|
||||
| "setTaskTitle"
|
||||
| "flushRouterModels"
|
||||
| "requestRouterModels"
|
||||
| "requestOpenAiModels"
|
||||
|
|
|
|||
|
|
@ -2479,13 +2479,19 @@ export class ClineProvider
|
|||
const existingItemIndex = history.findIndex((h) => h.id === item.id)
|
||||
|
||||
if (existingItemIndex !== -1) {
|
||||
// Preserve existing metadata (e.g., delegation fields) unless explicitly overwritten.
|
||||
// This prevents loss of status/awaitingChildId/delegatedToId when tasks are reopened,
|
||||
// terminated, or when routine message persistence occurs.
|
||||
history[existingItemIndex] = {
|
||||
...history[existingItemIndex],
|
||||
const existingItem = history[existingItemIndex]
|
||||
const hasTitleProp = Object.prototype.hasOwnProperty.call(item, "title")
|
||||
// Preserve existing metadata unless explicitly overwritten.
|
||||
// Title is only cleared when explicitly provided (including undefined).
|
||||
const mergedItem: HistoryItem = {
|
||||
...existingItem,
|
||||
...item,
|
||||
}
|
||||
if (!hasTitleProp) {
|
||||
mergedItem.title = existingItem.title
|
||||
}
|
||||
|
||||
history[existingItemIndex] = mergedItem
|
||||
} else {
|
||||
history.push(item)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1230,4 +1230,63 @@ describe("ClineProvider - Sticky Mode", () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateTaskHistory", () => {
|
||||
beforeEach(async () => {
|
||||
await provider.resolveWebviewView(mockWebviewView)
|
||||
})
|
||||
|
||||
it("preserves existing title when update omits the title property", async () => {
|
||||
const baseItem: HistoryItem = {
|
||||
id: "task-with-title",
|
||||
number: 1,
|
||||
ts: Date.now(),
|
||||
task: "Original task",
|
||||
tokensIn: 10,
|
||||
tokensOut: 20,
|
||||
cacheWrites: 0,
|
||||
cacheReads: 0,
|
||||
totalCost: 0,
|
||||
title: "Custom title",
|
||||
}
|
||||
|
||||
await provider.updateTaskHistory(baseItem)
|
||||
|
||||
const itemWithoutTitle = { ...baseItem }
|
||||
delete (itemWithoutTitle as any).title
|
||||
itemWithoutTitle.tokensIn = 42
|
||||
|
||||
await provider.updateTaskHistory(itemWithoutTitle as HistoryItem)
|
||||
|
||||
const history = mockContext.globalState.get("taskHistory") as HistoryItem[]
|
||||
expect(history[0]?.title).toBe("Custom title")
|
||||
})
|
||||
|
||||
it("allows clearing a title when explicitly set to undefined", async () => {
|
||||
const baseItem: HistoryItem = {
|
||||
id: "task-clear-title",
|
||||
number: 1,
|
||||
ts: Date.now(),
|
||||
task: "Another task",
|
||||
tokensIn: 5,
|
||||
tokensOut: 15,
|
||||
cacheWrites: 0,
|
||||
cacheReads: 0,
|
||||
totalCost: 0,
|
||||
title: "Temporary title",
|
||||
}
|
||||
|
||||
await provider.updateTaskHistory(baseItem)
|
||||
|
||||
const clearedItem: HistoryItem = {
|
||||
...baseItem,
|
||||
title: undefined,
|
||||
}
|
||||
|
||||
await provider.updateTaskHistory(clearedItem)
|
||||
|
||||
const history = mockContext.globalState.get("taskHistory") as HistoryItem[]
|
||||
expect(history[0]?.title).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
type Language,
|
||||
type GlobalState,
|
||||
type ClineMessage,
|
||||
type HistoryItem,
|
||||
type TelemetrySetting,
|
||||
type UserSettingsConfig,
|
||||
type ModelRecord,
|
||||
|
|
@ -728,6 +729,57 @@ export const webviewMessageHandler = async (
|
|||
vscode.window.showErrorMessage(t("common:errors.share_task_failed"))
|
||||
}
|
||||
break
|
||||
case "setTaskTitle": {
|
||||
const ids = Array.isArray(message.ids)
|
||||
? Array.from(
|
||||
new Set(
|
||||
message.ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0),
|
||||
),
|
||||
)
|
||||
: []
|
||||
if (ids.length === 0) {
|
||||
break
|
||||
}
|
||||
|
||||
const rawTitle = message.text ?? ""
|
||||
const trimmedTitle = rawTitle.trim()
|
||||
const normalizedTitle = trimmedTitle.length > 0 ? trimmedTitle : undefined
|
||||
const { taskHistory } = await provider.getState()
|
||||
if (!Array.isArray(taskHistory) || taskHistory.length === 0) {
|
||||
break
|
||||
}
|
||||
|
||||
let hasUpdates = false
|
||||
const historyById = new Map(taskHistory.map((item) => [item.id, item] as const))
|
||||
|
||||
for (const id of ids) {
|
||||
const existingItem = historyById.get(id)
|
||||
if (!existingItem) {
|
||||
console.warn(`[setTaskTitle] Unable to locate task history item with id ${id}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const normalizedExistingTitle =
|
||||
existingItem.title && existingItem.title.trim().length > 0 ? existingItem.title.trim() : undefined
|
||||
if (normalizedExistingTitle === normalizedTitle) {
|
||||
continue
|
||||
}
|
||||
|
||||
const updatedItem: HistoryItem = {
|
||||
...existingItem,
|
||||
title: normalizedTitle,
|
||||
}
|
||||
|
||||
await provider.updateTaskHistory(updatedItem)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
await provider.postStateToWebview()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case "showTaskWithId":
|
||||
provider.showTaskWithId(message.text!)
|
||||
break
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { memo, useEffect, useRef, useState, useMemo } from "react"
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
|
||||
import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog"
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
HardDriveUpload,
|
||||
FoldVertical,
|
||||
Globe,
|
||||
Pencil,
|
||||
} from "lucide-react"
|
||||
import prettyBytes from "pretty-bytes"
|
||||
|
||||
|
|
@ -26,6 +28,7 @@ import { StandardTooltip, Button } from "@src/components/ui"
|
|||
import { useExtensionState } from "@src/context/ExtensionStateContext"
|
||||
import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel"
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
import { DecoratedVSCodeTextField } from "@src/components/common/DecoratedVSCodeTextField"
|
||||
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
|
||||
|
|
@ -67,7 +70,13 @@ const TaskHeader = ({
|
|||
todos,
|
||||
}: TaskHeaderProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = useExtensionState()
|
||||
const {
|
||||
apiConfiguration,
|
||||
currentTaskItem,
|
||||
clineMessages,
|
||||
isBrowserSessionActive,
|
||||
taskTitlesEnabled = false,
|
||||
} = useExtensionState()
|
||||
const { id: modelId, info: model } = useSelectedModel(apiConfiguration)
|
||||
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
|
||||
const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false)
|
||||
|
|
@ -99,6 +108,103 @@ const TaskHeader = ({
|
|||
return () => clearTimeout(timer)
|
||||
}, [currentTaskItem, isTaskComplete])
|
||||
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false)
|
||||
const [titleInput, setTitleInput] = useState(currentTaskItem?.title ?? "")
|
||||
const titleInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const skipBlurSubmitRef = useRef(false)
|
||||
const currentTitle = currentTaskItem?.title?.trim() ?? ""
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditingTitle) {
|
||||
setTitleInput(currentTaskItem?.title ?? "")
|
||||
}
|
||||
}, [currentTaskItem?.title, isEditingTitle])
|
||||
|
||||
useEffect(() => {
|
||||
setIsEditingTitle(false)
|
||||
}, [currentTaskItem?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskTitlesEnabled) {
|
||||
setIsEditingTitle(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditingTitle) {
|
||||
skipBlurSubmitRef.current = false
|
||||
requestAnimationFrame(() => {
|
||||
titleInputRef.current?.focus()
|
||||
titleInputRef.current?.select()
|
||||
})
|
||||
}
|
||||
}, [isEditingTitle, taskTitlesEnabled])
|
||||
|
||||
const submitTitle = useCallback(() => {
|
||||
if (!taskTitlesEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentTaskItem) {
|
||||
setIsEditingTitle(false)
|
||||
return
|
||||
}
|
||||
|
||||
const trimmed = titleInput.trim()
|
||||
const existingTrimmed = currentTaskItem.title?.trim() ?? ""
|
||||
|
||||
setIsEditingTitle(false)
|
||||
|
||||
if (trimmed === existingTrimmed) {
|
||||
setTitleInput(currentTaskItem.title ?? "")
|
||||
return
|
||||
}
|
||||
|
||||
vscode.postMessage({
|
||||
type: "setTaskTitle",
|
||||
text: trimmed,
|
||||
ids: [currentTaskItem.id],
|
||||
})
|
||||
|
||||
setTitleInput(trimmed)
|
||||
}, [currentTaskItem, taskTitlesEnabled, titleInput])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditingTitle) {
|
||||
skipBlurSubmitRef.current = false
|
||||
}
|
||||
}, [isEditingTitle])
|
||||
|
||||
const handleTitleBlur = useCallback(() => {
|
||||
if (!taskTitlesEnabled) {
|
||||
return
|
||||
}
|
||||
if (skipBlurSubmitRef.current) {
|
||||
skipBlurSubmitRef.current = false
|
||||
return
|
||||
}
|
||||
submitTitle()
|
||||
}, [submitTitle, taskTitlesEnabled])
|
||||
|
||||
const handleTitleKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (!taskTitlesEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
skipBlurSubmitRef.current = true
|
||||
submitTitle()
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
skipBlurSubmitRef.current = true
|
||||
setIsEditingTitle(false)
|
||||
setTitleInput(currentTaskItem?.title ?? "")
|
||||
}
|
||||
},
|
||||
[currentTaskItem?.title, submitTitle, taskTitlesEnabled],
|
||||
)
|
||||
|
||||
const textContainerRef = useRef<HTMLDivElement>(null)
|
||||
const textRef = useRef<HTMLDivElement>(null)
|
||||
const contextWindow = model?.contextWindow || 1
|
||||
|
|
@ -124,7 +230,96 @@ const TaskHeader = ({
|
|||
/>
|
||||
)
|
||||
|
||||
const renderTitleEditor = () => (
|
||||
<div onClick={(event) => event.stopPropagation()} className="w-full" data-testid="task-title-editor">
|
||||
<DecoratedVSCodeTextField
|
||||
ref={titleInputRef}
|
||||
value={titleInput}
|
||||
onInput={(event: any) => setTitleInput(event.target.value)}
|
||||
onBlur={handleTitleBlur}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
placeholder={t("chat:task.titlePlaceholder")}
|
||||
data-testid="task-title-input"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderTitleAction = () => {
|
||||
if (!taskTitlesEnabled || !currentTaskItem || isEditingTitle) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tooltipKey = currentTitle.length > 0 ? "chat:task.editTitle" : "chat:task.addTitle"
|
||||
|
||||
return (
|
||||
<StandardTooltip content={t(tooltipKey)}>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 min-h-[20px] min-w-[20px] p-[2px] cursor-pointer opacity-85 hover:opacity-100 bg-transparent border-none rounded-md text-inherit"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
skipBlurSubmitRef.current = false
|
||||
setTitleInput(currentTitle)
|
||||
setIsEditingTitle(true)
|
||||
}}
|
||||
aria-label={t(tooltipKey)}
|
||||
data-testid="task-title-edit-button">
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</StandardTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const renderCollapsedTitleContent = () => {
|
||||
if (!taskTitlesEnabled || !currentTaskItem) {
|
||||
return (
|
||||
<span className="whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<Mention text={task.text} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (isEditingTitle) {
|
||||
return renderTitleEditor()
|
||||
}
|
||||
|
||||
if (currentTitle.length > 0) {
|
||||
return (
|
||||
<span className="truncate text-base" data-testid="task-title-text">
|
||||
{currentTitle}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<Mention text={task.text} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderExpandedTitleContent = () => {
|
||||
if (!taskTitlesEnabled || !currentTaskItem) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isEditingTitle) {
|
||||
return renderTitleEditor()
|
||||
}
|
||||
|
||||
if (currentTitle.length > 0) {
|
||||
return (
|
||||
<span className="text-base" data-testid="task-title-text">
|
||||
{currentTitle}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const hasTodos = todos && Array.isArray(todos) && todos.length > 0
|
||||
const expandedTitleContent = renderExpandedTitleContent()
|
||||
|
||||
return (
|
||||
<div className="group pt-2 pb-0 px-3">
|
||||
|
|
@ -175,18 +370,25 @@ const TaskHeader = ({
|
|||
<div className="flex justify-between items-center gap-0">
|
||||
<div className="flex items-center select-none grow min-w-0">
|
||||
<div className="grow min-w-0">
|
||||
{isTaskExpanded && <span className="font-bold">{t("chat:task.title")}</span>}
|
||||
{!isTaskExpanded && (
|
||||
<div className="flex items-center gap-2">
|
||||
{isTaskExpanded ? (
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">{t("chat:task.title")}</span>
|
||||
{renderTitleAction()}
|
||||
</div>
|
||||
{expandedTitleContent ? <div className="min-w-0">{expandedTitleContent}</div> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<SquarePen className="size-3 shrink-0" />
|
||||
<span className="whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<Mention text={task.text} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">{renderCollapsedTitleContent()}</div>
|
||||
{renderTitleAction()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center shrink-0 ml-2" onClick={(e) => e.stopPropagation()}>
|
||||
<StandardTooltip content={isTaskExpanded ? t("chat:task.collapse") : t("chat:task.expand")}>
|
||||
<StandardTooltip
|
||||
content={isTaskExpanded ? t("chat:task.collapse") : t("chat:task.expand")}>
|
||||
<button
|
||||
onClick={() => setIsTaskExpanded(!isTaskExpanded)}
|
||||
className="shrink-0 min-h-[20px] min-w-[20px] p-[2px] cursor-pointer opacity-85 hover:opacity-100 bg-transparent border-none rounded-md">
|
||||
|
|
@ -352,7 +554,7 @@ const TaskHeader = ({
|
|||
{t("chat:task.contextWindow")}
|
||||
</th>
|
||||
<td className="font-light align-top">
|
||||
<div className={`max-w-md -mt-1.5 flex flex-nowrap gap-1`}>
|
||||
<div className="max-w-md -mt-1.5 flex flex-nowrap gap-1">
|
||||
<ContextWindowProgress
|
||||
contextWindow={contextWindow}
|
||||
contextTokens={contextTokens || 0}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
|||
import type { ProviderSettings } from "@roo-code/types"
|
||||
|
||||
import TaskHeader, { TaskHeaderProps } from "../TaskHeader"
|
||||
import { vscode } from "@/utils/vscode"
|
||||
|
||||
// Mock i18n
|
||||
vi.mock("react-i18next", () => ({
|
||||
|
|
@ -27,9 +28,20 @@ vi.mock("@/utils/vscode", () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
// Mock the VSCodeBadge component
|
||||
// Mock the VSCodeBadge/TextField components
|
||||
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
|
||||
VSCodeBadge: ({ children }: { children: React.ReactNode }) => <div data-testid="vscode-badge">{children}</div>,
|
||||
VSCodeTextField: React.forwardRef<HTMLInputElement, any>(
|
||||
({ onInput, "data-testid": dataTestId, value = "", ...rest }: any, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
data-testid={dataTestId}
|
||||
value={value}
|
||||
onChange={(event) => onInput?.({ target: event.target })}
|
||||
{...rest}
|
||||
/>
|
||||
),
|
||||
),
|
||||
}))
|
||||
|
||||
// Create a variable to hold the mock state
|
||||
|
|
@ -89,6 +101,18 @@ vi.mock("@roo/array", () => ({
|
|||
}))
|
||||
|
||||
describe("TaskHeader", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExtensionState = {
|
||||
apiConfiguration: {
|
||||
apiProvider: "anthropic",
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-3-opus-20240229",
|
||||
} as ProviderSettings,
|
||||
currentTaskItem: { id: "test-task-id" },
|
||||
clineMessages: [],
|
||||
}
|
||||
})
|
||||
const defaultProps: TaskHeaderProps = {
|
||||
task: { type: "say", ts: Date.now(), text: "Test task", images: [] },
|
||||
tokensIn: 100,
|
||||
|
|
@ -180,6 +204,23 @@ describe("TaskHeader", () => {
|
|||
expect(handleCondenseContext).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("posts setTaskTitle message when editing title", () => {
|
||||
renderTaskHeader()
|
||||
|
||||
const editButton = screen.getByTestId("task-title-edit-button")
|
||||
fireEvent.click(editButton)
|
||||
|
||||
const input = screen.getByTestId("task-title-input")
|
||||
fireEvent.change(input, { target: { value: "New task title" } })
|
||||
fireEvent.keyDown(input, { key: "Enter", code: "Enter" })
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "setTaskTitle",
|
||||
text: "New task title",
|
||||
ids: ["test-task-id"],
|
||||
})
|
||||
})
|
||||
|
||||
describe("DismissibleUpsell behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import TaskItemFooter from "./TaskItemFooter"
|
|||
|
||||
interface DisplayHistoryItem extends HistoryItem {
|
||||
highlight?: string
|
||||
titleHighlight?: string
|
||||
}
|
||||
|
||||
interface TaskItemProps {
|
||||
|
|
@ -69,6 +70,26 @@ const TaskItem = ({
|
|||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{(item.title || item.titleHighlight) &&
|
||||
(item.titleHighlight ? (
|
||||
<div
|
||||
className={cn("text-vscode-foreground font-semibold truncate mb-1", {
|
||||
"text-base": !isCompact,
|
||||
"text-sm": isCompact,
|
||||
})}
|
||||
data-testid="task-item-title"
|
||||
dangerouslySetInnerHTML={{ __html: item.titleHighlight }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn("text-vscode-foreground font-semibold truncate mb-1", {
|
||||
"text-base": !isCompact,
|
||||
"text-sm": isCompact,
|
||||
})}
|
||||
data-testid="task-item-title">
|
||||
{item.title}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden whitespace-pre-wrap font-light text-vscode-foreground text-ellipsis line-clamp-3",
|
||||
|
|
|
|||
|
|
@ -80,6 +80,21 @@ describe("TaskItem", () => {
|
|||
expect(screen.getByTestId("export")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders title above task content when provided", () => {
|
||||
render(
|
||||
<TaskItem
|
||||
item={{ ...mockTask, title: "Important task" }}
|
||||
variant="full"
|
||||
isSelected={false}
|
||||
onToggleSelection={vi.fn()}
|
||||
isSelectionMode={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("task-item-title")).toHaveTextContent("Important task")
|
||||
expect(screen.getByTestId("task-content")).toHaveTextContent("Test task")
|
||||
})
|
||||
|
||||
it("displays time ago information", () => {
|
||||
render(
|
||||
<TaskItem
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const mockTaskHistory: HistoryItem[] = [
|
|||
{
|
||||
id: "task-1",
|
||||
number: 1,
|
||||
title: "Build component",
|
||||
task: "Create a React component",
|
||||
ts: new Date("2022-02-16T12:00:00").getTime(),
|
||||
tokensIn: 100,
|
||||
|
|
@ -30,6 +31,7 @@ const mockTaskHistory: HistoryItem[] = [
|
|||
{
|
||||
id: "task-2",
|
||||
number: 2,
|
||||
title: "Write tests",
|
||||
task: "Write unit tests",
|
||||
ts: new Date("2022-02-17T12:00:00").getTime(),
|
||||
tokensIn: 200,
|
||||
|
|
@ -154,6 +156,19 @@ describe("useTaskSearch", () => {
|
|||
expect((result.current.tasks[0] as any).highlight).toBe("<mark>Create a React component</mark>")
|
||||
})
|
||||
|
||||
it("matches search queries against task titles", () => {
|
||||
const { result } = renderHook(() => useTaskSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowAllWorkspaces(true)
|
||||
result.current.setSearchQuery("build")
|
||||
})
|
||||
|
||||
expect(result.current.tasks).toHaveLength(1)
|
||||
expect(result.current.tasks[0].id).toBe("task-1")
|
||||
expect((result.current.tasks[0] as any).titleHighlight).toBe("<mark>Build component</mark>")
|
||||
})
|
||||
|
||||
it("automatically switches to mostRelevant when searching", () => {
|
||||
const { result } = renderHook(() => useTaskSearch())
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export const useTaskSearch = () => {
|
|||
|
||||
const fzf = useMemo(() => {
|
||||
return new Fzf(presentableTasks, {
|
||||
selector: (item) => item.task,
|
||||
selector: (item) => (item.title ? `${item.title} ${item.task}` : item.task),
|
||||
})
|
||||
}, [presentableTasks])
|
||||
|
||||
|
|
@ -44,14 +44,24 @@ export const useTaskSearch = () => {
|
|||
const searchResults = fzf.find(searchQuery)
|
||||
results = searchResults.map((result) => {
|
||||
const positions = Array.from(result.positions)
|
||||
const taskEndIndex = result.item.task.length
|
||||
const titleLength = result.item.title ? result.item.title.length : 0
|
||||
const separatorLength = titleLength > 0 ? 1 : 0
|
||||
const taskOffset = titleLength + separatorLength
|
||||
const titlePositions = titleLength > 0 ? positions.filter((p) => p < titleLength) : []
|
||||
const taskPositions = positions.filter((p) => p >= taskOffset).map((p) => p - taskOffset)
|
||||
|
||||
const titleHighlight =
|
||||
titlePositions.length > 0 && result.item.title
|
||||
? highlightFzfMatch(result.item.title, titlePositions)
|
||||
: undefined
|
||||
|
||||
const taskHighlight =
|
||||
taskPositions.length > 0 ? highlightFzfMatch(result.item.task, taskPositions) : undefined
|
||||
|
||||
return {
|
||||
...result.item,
|
||||
highlight: highlightFzfMatch(
|
||||
result.item.task,
|
||||
positions.filter((p) => p < taskEndIndex),
|
||||
),
|
||||
titleHighlight,
|
||||
highlight: taskHighlight,
|
||||
workspace: result.item.workspace,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
"apiCost": "API Cost",
|
||||
"size": "Size",
|
||||
"condenseContext": "Intelligently condense context",
|
||||
"addTitle": "Add title",
|
||||
"editTitle": "Edit title",
|
||||
"titlePlaceholder": "Add a title",
|
||||
"contextWindow": "Context Length",
|
||||
"closeAndStart": "Close task and start a new one",
|
||||
"export": "Export task history",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue