diff --git a/apps/vscode-e2e/src/suite/index.ts b/apps/vscode-e2e/src/suite/index.ts index 0d53d65b2b..bcc229890c 100644 --- a/apps/vscode-e2e/src/suite/index.ts +++ b/apps/vscode-e2e/src/suite/index.ts @@ -27,6 +27,9 @@ export async function run() { openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!, openRouterModelId: "openai/gpt-4.1", ...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }), + // Title requests embed the task prompt, so they match prompt-substring fixtures and + // consume their sequenceIndex slots ahead of the task's own requests. + taskTitleGenerationEnabled: false, }) await vscode.commands.executeCommand("zoo-code.SidebarProvider.focus") diff --git a/apps/vscode-e2e/src/visual/sceneController.ts b/apps/vscode-e2e/src/visual/sceneController.ts index d8857ae043..e5abf55c82 100644 --- a/apps/vscode-e2e/src/visual/sceneController.ts +++ b/apps/vscode-e2e/src/visual/sceneController.ts @@ -108,6 +108,8 @@ export async function run(): Promise { openRouterModelId: "openai/gpt-4.1", openRouterBaseUrl: `${aimockUrl}/v1`, telemetrySetting: "disabled", + // A generated title lands asynchronously and would race the task header screenshot. + taskTitleGenerationEnabled: false, }) await log(`aimock=${aimockUrl}`) let completionHandler: ((event: { message: ClineMessage }) => void) | undefined diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index f5757a51c7..2bd5573039 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -54,6 +54,9 @@ export const DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT = false export const DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED = false +/** Whether new root tasks get a short generated title in the background. */ +export const DEFAULT_TASK_TITLE_GENERATION_ENABLED = true + /** * Whether commands that are not explicitly auto-approved are automatically * denied (with a structured reason sent to the model) instead of prompting the @@ -273,6 +276,8 @@ export const globalSettingsSchema = z.object({ customSupportPrompts: customSupportPromptsSchema.optional(), enhancementApiConfigId: z.string().optional(), includeTaskHistoryInEnhance: z.boolean().optional(), + taskTitleGenerationEnabled: z.boolean().optional(), + taskTitleApiConfigId: z.string().optional(), historyPreviewCollapsed: z.boolean().optional(), reasoningBlockCollapsed: z.boolean().optional(), /** diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 131194212e..1ba91b02e4 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -34,6 +34,7 @@ export const historyItemSchema = z.object({ ts: z.number(), task: z.string(), title: z.string().optional(), // User-assigned display name; `task` keeps the original prompt + autoTitle: z.string().optional(), // Generated display name; `title` (manual) takes precedence tokensIn: z.number(), tokensOut: z.number(), cacheWrites: z.number().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 8a006c0639..02fc7b0463 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -340,6 +340,8 @@ export type ExtensionState = Pick< | "imageGenerationProvider" | "openRouterImageGenerationSelectedModel" | "includeTaskHistoryInEnhance" + | "taskTitleGenerationEnabled" + | "taskTitleApiConfigId" | "reasoningBlockCollapsed" | "chatFontSize" | "timeFormat" diff --git a/scripts/check-task-store-concurrency.ts b/scripts/check-task-store-concurrency.ts index cf6f5f7d64..a5ac112145 100644 --- a/scripts/check-task-store-concurrency.ts +++ b/scripts/check-task-store-concurrency.ts @@ -25,6 +25,10 @@ type OperationId = | "stale-save-a" | "abandon-b" | "reject-a" + | "rename-a" + | "auto-title-a" + | "auto-title-b" + | "delete-a" type RecordMap = Partial> interface PreparedWrite { @@ -34,7 +38,7 @@ interface PreparedWrite { } interface OperationState { - phase: "idle" | "read" | "prepared" | "revalidated" | "done" | "rejected" | "failed" + phase: "idle" | "read" | "prepared" | "revalidated" | "done" | "rejected" | "skipped" | "failed" snapshot?: RecordMap writes?: PreparedWrite[] writeIndex: number @@ -64,6 +68,13 @@ interface OperationSpec { externalSnapshot?: boolean allowRefreshAfterRead?: boolean publishCacheAtEnd?: boolean + /** + * "auto-title" mirrors TaskHistoryStore.setAutoTitleIfAbsent: under the file lock the write is + * skipped when the persisted record is gone or already carries an autoTitle, and a commit copies + * only autoTitle into the host cache. + * "delete" removes the persisted record. + */ + kind?: "auto-title" | "delete" isEnabled?(snapshot: RecordMap): boolean buildWrites(snapshot: RecordMap): HistoryItem[] } @@ -113,6 +124,12 @@ const semanticLandmarks = { state.commits.filter((entry) => entry.operationId === "complete-a").length === 1 && state.caches.A["child-a"]?.status === "completed" && state.caches.A.parent?.status === "delegated", + "auto-title-skipped-after-delete": (state: ModelState) => + Object.values(state.operations).some((operation) => operation.phase === "skipped") && + state.operations["delete-a"]?.phase === "done" && + state.disk["child-a"] === undefined, + "manual-and-auto-title-coexist": (state: ModelState) => + Boolean(state.disk["child-a"]?.title) && Boolean(state.disk["child-a"]?.autoTitle), } satisfies Record boolean> function item(id: TaskId, overrides: Partial = {}): HistoryItem { @@ -248,6 +265,32 @@ const operationSpecs: Record = { return [abandoned.child, abandoned.parent] }, }, + "rename-a": { + id: "rename-a", + host: "A", + buildWrites: (snapshot) => [{ ...getRequired(snapshot, "child-a"), title: "manual" }], + }, + "auto-title-a": { + id: "auto-title-a", + host: "A", + kind: "auto-title", + isEnabled: (snapshot) => snapshot["child-a"] !== undefined, + buildWrites: (snapshot) => [{ ...getRequired(snapshot, "child-a"), autoTitle: "generated-a" }], + }, + "auto-title-b": { + id: "auto-title-b", + host: "B", + kind: "auto-title", + isEnabled: (snapshot) => snapshot["child-a"] !== undefined, + buildWrites: (snapshot) => [{ ...getRequired(snapshot, "child-a"), autoTitle: "generated-b" }], + }, + "delete-a": { + id: "delete-a", + host: "A", + kind: "delete", + isEnabled: (snapshot) => snapshot["child-a"] !== undefined, + buildWrites: (snapshot) => [getRequired(snapshot, "child-a")], + }, "reject-a": { id: "reject-a", host: "A", @@ -316,6 +359,13 @@ function nextSteps(state: ModelState, scenario: Scenario): TraceStep[] { const target = next.operations[spec.id]! const targetWrite = target.writes![target.writeIndex]! next.locks[targetWrite.taskId] = spec.id + const persisted = next.disk[targetWrite.taskId] + if (spec.kind === "auto-title" && (!persisted || persisted.autoTitle)) { + target.phase = "skipped" + delete next.locks[targetWrite.taskId] + delete next.hostMutexes[spec.host] + return + } try { target.candidate = mergeHistoryDelta( next.disk[targetWrite.taskId], @@ -342,15 +392,29 @@ function nextSteps(state: ModelState, scenario: Scenario): TraceStep[] { throw new Error(`${spec.id} committed without owning ${targetWrite.taskId}`) } const previous = next.disk[targetWrite.taskId] - next.disk[targetWrite.taskId] = target.candidate - if (!spec.publishCacheAtEnd) next.caches[spec.host][targetWrite.taskId] = target.candidate - next.commits.push({ - operationId: spec.id, - taskId: targetWrite.taskId, - previous, - delta: targetWrite.delta, - next: target.candidate, - }) + if (spec.kind === "delete") { + delete next.disk[targetWrite.taskId] + delete next.caches[spec.host][targetWrite.taskId] + } else { + next.disk[targetWrite.taskId] = target.candidate + const cached = next.caches[spec.host][targetWrite.taskId] + if (spec.kind === "auto-title" && cached) { + // setAutoTitleIfAbsent copies only autoTitle so the cache stays this host's save baseline. + next.caches[spec.host][targetWrite.taskId] = { + ...cached, + autoTitle: target.candidate.autoTitle, + } + } else if (!spec.publishCacheAtEnd) { + next.caches[spec.host][targetWrite.taskId] = target.candidate + } + next.commits.push({ + operationId: spec.id, + taskId: targetWrite.taskId, + previous, + delta: targetWrite.delta, + next: target.candidate, + }) + } delete next.locks[targetWrite.taskId] target.candidate = undefined target.writeIndex++ @@ -681,6 +745,59 @@ const scenarios: Scenario[] = [ }, check: () => [], }, + { + name: "auto title vs manual rename", + operations: [operationSpecs["rename-a"], operationSpecs["auto-title-b"]], + check: (state) => { + if (state.operations["rename-a"]?.phase !== "done" || state.operations["auto-title-b"]?.phase !== "done") { + return [] + } + const record = state.disk["child-a"] + return record?.title === "manual" && record.autoTitle === "generated-b" + ? [] + : ["auto title and manual title did not both survive"] + }, + }, + { + name: "auto title vs stale save", + operations: [operationSpecs["stale-save-a"], operationSpecs["auto-title-b"]], + check: (state) => { + if ( + state.operations["stale-save-a"]?.phase !== "done" || + state.operations["auto-title-b"]?.phase !== "done" + ) { + return [] + } + const record = state.disk["child-a"] + return record?.autoTitle === "generated-b" && record.tokensOut === 1 + ? [] + : ["stale save erased the auto title or lost its own update"] + }, + }, + { + name: "auto title vs delete", + operations: [operationSpecs["delete-a"], operationSpecs["auto-title-b"]], + check: (state) => { + const autoTitle = state.operations["auto-title-b"]?.phase + if (state.operations["delete-a"]?.phase !== "done" || (autoTitle !== "done" && autoTitle !== "skipped")) { + return [] + } + return state.disk["child-a"] === undefined ? [] : ["auto title resurrected a deleted record"] + }, + }, + { + name: "auto title never overwritten", + operations: [operationSpecs["auto-title-a"], operationSpecs["auto-title-b"]], + check: (state) => { + const phases = [state.operations["auto-title-a"]?.phase, state.operations["auto-title-b"]?.phase] + if (!phases.every((phase) => phase === "done" || phase === "skipped")) return [] + const titleCommits = state.commits.filter((entry) => entry.taskId === "child-a" && entry.delta.autoTitle) + const first = titleCommits[0] + return titleCommits.length === 1 && state.disk["child-a"]?.autoTitle === first?.next.autoTitle + ? [] + : ["a second auto title overwrote the first"] + }, + }, ] let totalStates = 0 diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index eceb4cbeb0..6c8157f1cd 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -1205,6 +1205,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { // The real deletion path: the store swallows a per-file history // lock failure, so deleteMany resolves and cleanup continues. deleteTaskWithId: ClineProvider.prototype.deleteTaskWithId, + taskTitleService: { cancel: vi.fn() }, createTaskWithHistoryItem, log: vi.fn(), postStateToWebview: vi.fn().mockResolvedValue(undefined), diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index c1c50cca1e..38a4b5e91e 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -71,6 +71,8 @@ interface DelegationRepairIntent { /** * Options for TaskHistoryStore constructor. */ +type AutoTitleDiskRead = { kind: "found"; item: HistoryItem } | { kind: "missing" } | { kind: "unreadable" } + export interface TaskHistoryStoreOptions { /** * Optional callback invoked inside the write lock after each mutation @@ -1024,6 +1026,100 @@ export class TaskHistoryStore { }) } + /** + * Record a generated title unless the task already has one. The persisted record is re-checked + * under the per-file lock, so a task deleted by another host is never resurrected and a title + * written by another host is never overwritten. Only a confirmed missing file evicts the cache: + * a read or parse failure leaves it intact so a later save still merges against a known record. + */ + public setAutoTitleIfAbsent(taskId: string, autoTitle: string): Promise<{ applied: boolean; item?: HistoryItem }> { + return this.withLock(async () => { + const title = autoTitle.trim() + const disk = await this.readTaskFileForAutoTitle(taskId) + if (disk.kind !== "found") { + return this.settleSkippedAutoTitle(taskId, disk) + } + if (disk.item.autoTitle || !title) { + return this.settleSkippedAutoTitle(taskId, disk) + } + + const skipped = Symbol("auto-title-skipped") + let persisted: HistoryItem | undefined + try { + const filePath = await this.getTaskFilePath(taskId) + await safeWriteJson( + filePath, + { ...disk.item, autoTitle: title }, + { + merge: (existing) => { + const current = existing as Partial | null + if (!current || current.id !== taskId || current.autoTitle) { + throw Object.assign(new Error("auto title skipped"), { [skipped]: true }) + } + persisted = { ...(current as HistoryItem), autoTitle: title } + return persisted + }, + }, + ) + } catch (error) { + if (!(error instanceof Error) || !(skipped in error)) { + throw error + } + // safeWriteJson reports both a missing and an unparsable file as `null`, so re-read to tell them apart. + return this.settleSkippedAutoTitle(taskId, await this.readTaskFileForAutoTitle(taskId)) + } + + if (!persisted) { + return { applied: false } + } + this.cacheAutoTitle(persisted) + if (this.onWrite) { + await this.onWrite(this.getAll()) + } + return { applied: true, item: persisted } + }) + } + + /** + * Copy only `autoTitle` from the disk record onto the cached one. The cache is the baseline that + * later saves diff against, so taking the peer's other fields here would let a stale snapshot + * from this host write its old values back over them. Callers still return the full disk record + * for display. + */ + private cacheAutoTitle(disk: HistoryItem): void { + const cached = this.cache.get(disk.id) + this.cache.set(disk.id, cached ? { ...cached, autoTitle: disk.autoTitle } : disk) + } + + private settleSkippedAutoTitle(taskId: string, disk: AutoTitleDiskRead): { applied: false; item?: HistoryItem } { + switch (disk.kind) { + case "found": + this.cacheAutoTitle(disk.item) + return { applied: false, item: disk.item } + case "missing": + this.cache.delete(taskId) + return { applied: false } + case "unreadable": + return { applied: false, item: this.cache.get(taskId) } + } + } + + private async readTaskFileForAutoTitle(taskId: string): Promise { + let raw: string + try { + raw = await readFileWithMissingRetry(await this.getTaskFilePath(taskId)) + } catch (error) { + return getErrorCode(error) === "ENOENT" ? { kind: "missing" } : { kind: "unreadable" } + } + try { + const item: unknown = JSON.parse(raw) + const id = typeof item === "object" && item !== null ? (item as { id?: unknown }).id : undefined + return id === taskId ? { kind: "found", item: item as HistoryItem } : { kind: "unreadable" } + } catch { + return { kind: "unreadable" } + } + } + /** * Update two related HistoryItems within a single in-process lock acquisition. * Both updaters run synchronously (no I/O, no lock re-entry). Both writes diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts index e3e4445f35..4081fc0d7a 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts @@ -421,4 +421,177 @@ describe("TaskHistoryStore real cross-host locking", () => { await fs.rm(storagePath, { recursive: true, force: true }) } }) + + describe("setAutoTitleIfAbsent", () => { + async function withTwoStores( + run: (a: TaskHistoryStore, b: TaskHistoryStore, storagePath: string) => Promise, + ) { + const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-auto-title-")) + const storeA = new TaskHistoryStore(storagePath) + const storeB = new TaskHistoryStore(storagePath) + try { + await storeA.initialize() + await storeA.upsert(item("shared-task")) + await storeB.initialize() + await run(storeA, storeB, storagePath) + } finally { + storeA.dispose() + storeB.dispose() + await fs.rm(storagePath, { recursive: true, force: true }) + } + } + + const taskFile = (storagePath: string) => path.join(storagePath, "tasks", "shared-task", "history_item.json") + + it("writes the title and trims it", async () => { + await withTwoStores(async (storeA, storeB, storagePath) => { + const result = await storeA.setAutoTitleIfAbsent("shared-task", " Fix login bug ") + expect(result.applied).toBe(true) + expect(result.item?.autoTitle).toBe("Fix login bug") + expect(JSON.parse(await fs.readFile(taskFile(storagePath), "utf8")).autoTitle).toBe("Fix login bug") + await storeB.invalidate("shared-task") + expect(storeB.get("shared-task")?.autoTitle).toBe("Fix login bug") + }) + }) + + it("keeps a title written by another host and refreshes the stale cache", async () => { + await withTwoStores(async (storeA, storeB) => { + await storeB.setAutoTitleIfAbsent("shared-task", "First title") + expect(storeA.get("shared-task")?.autoTitle).toBeUndefined() + + const result = await storeA.setAutoTitleIfAbsent("shared-task", "Second title") + expect(result.applied).toBe(false) + expect(result.item?.autoTitle).toBe("First title") + expect(storeA.get("shared-task")?.autoTitle).toBe("First title") + }) + }) + + it("does not recreate a task deleted by another host", async () => { + await withTwoStores(async (storeA, storeB, storagePath) => { + await storeB.delete("shared-task") + expect(storeA.get("shared-task")).toBeDefined() + + const result = await storeA.setAutoTitleIfAbsent("shared-task", "Late title") + expect(result).toEqual({ applied: false }) + expect(storeA.get("shared-task")).toBeUndefined() + await expect(fs.access(taskFile(storagePath))).rejects.toMatchObject({ code: "ENOENT" }) + }) + }) + + it("does not recreate a task deleted after the early disk read", async () => { + await withTwoStores(async (storeA, storeB, storagePath) => { + const stale = storeA.get("shared-task") + const readTaskFile: unknown = Reflect.get(storeA, "readTaskFile") + if (!stale || typeof readTaskFile !== "function") throw new Error("test setup failed") + const original = readTaskFile.bind(storeA) as (id: string) => Promise + let reads = 0 + Reflect.set(storeA, "readTaskFile", async (id: string) => (reads++ === 0 ? stale : original(id))) + + await storeB.delete("shared-task") + + const result = await storeA.setAutoTitleIfAbsent("shared-task", "Late title") + expect(result.applied).toBe(false) + expect(storeA.get("shared-task")).toBeUndefined() + await expect(fs.access(taskFile(storagePath))).rejects.toMatchObject({ code: "ENOENT" }) + }) + }) + + const ioError = () => Object.assign(new Error("EIO: i/o error, read"), { code: "EIO" }) + // Each case corrupts the read and returns the file content that must remain on disk. + const unreadableCases: Array<{ name: string; corrupt(storagePath: string, before: string): Promise }> = + [ + { + name: "a read error", + corrupt: async (_storagePath, before) => { + vi.mocked(fs.readFile).mockRejectedValueOnce(ioError()) + return before + }, + }, + { + name: "unparsable JSON", + corrupt: async (storagePath) => { + await fs.writeFile(taskFile(storagePath), "{ torn") + return "{ torn" + }, + }, + { + name: "a record with a different id", + corrupt: async (storagePath) => { + const other = JSON.stringify(item("other-task")) + await fs.writeFile(taskFile(storagePath), other) + return other + }, + }, + ] + + it.each(unreadableCases)("keeps the cache and writes nothing on $name", async ({ corrupt }) => { + await withTwoStores(async (storeA, _storeB, storagePath) => { + const cached = storeA.get("shared-task") + const expectedOnDisk = await corrupt(storagePath, await fs.readFile(taskFile(storagePath), "utf8")) + + const result = await storeA.setAutoTitleIfAbsent("shared-task", "Title") + expect(result.applied).toBe(false) + expect(storeA.get("shared-task")).toEqual(cached) + expect(await fs.readFile(taskFile(storagePath), "utf8")).toBe(expectedOnDisk) + }) + }) + + it("does not let a stale save overwrite a peer update after a read error", async () => { + await withTwoStores(async (storeA, storeB) => { + await storeA.upsert({ ...item("shared-task"), mode: "code" }) + const stale = storeA.get("shared-task")! + await storeB.invalidate("shared-task") + await storeB.upsert({ ...storeB.get("shared-task")!, mode: "architect" }) + + vi.mocked(fs.readFile).mockRejectedValueOnce(ioError()) + await storeA.setAutoTitleIfAbsent("shared-task", "Title") + await storeA.upsert({ ...stale, tokensOut: 5 }) + + await storeB.invalidate("shared-task") + expect(storeB.get("shared-task")).toMatchObject({ mode: "architect", tokensOut: 5 }) + }) + }) + + it.each([ + { name: "writes the title", peerTitle: undefined, expectedTitle: "Title" }, + { name: "skips a peer's title", peerTitle: "Peer title", expectedTitle: "Peer title" }, + ])("does not let a stale save overwrite a peer update after it $name", async ({ peerTitle, expectedTitle }) => { + await withTwoStores(async (storeA, storeB) => { + await storeA.upsert({ ...item("shared-task"), mode: "code" }) + const stale = storeA.get("shared-task")! + await storeB.invalidate("shared-task") + await storeB.upsert({ ...storeB.get("shared-task")!, mode: "architect" }) + if (peerTitle) await storeB.setAutoTitleIfAbsent("shared-task", peerTitle) + + const result = await storeA.setAutoTitleIfAbsent("shared-task", "Title") + expect(result.applied).toBe(!peerTitle) + expect(storeA.get("shared-task")).toMatchObject({ mode: "code", autoTitle: expectedTitle }) + await storeA.upsert({ ...stale, tokensOut: 5 }) + + await storeB.invalidate("shared-task") + expect(storeB.get("shared-task")).toMatchObject({ + mode: "architect", + tokensOut: 5, + autoTitle: expectedTitle, + }) + }) + }) + + it.each(["", " \n\t"])("rejects a blank title %j", async (blank) => { + await withTwoStores(async (storeA, _storeB, storagePath) => { + const result = await storeA.setAutoTitleIfAbsent("shared-task", blank) + expect(result.applied).toBe(false) + expect(JSON.parse(await fs.readFile(taskFile(storagePath), "utf8")).autoTitle).toBeUndefined() + }) + }) + + it("does not revert a concurrent manual title when writing the auto title", async () => { + await withTwoStores(async (storeA, storeB) => { + await storeB.atomicReadAndUpdate("shared-task", (current) => ({ ...current, title: "Manual" })) + + const result = await storeA.setAutoTitleIfAbsent("shared-task", "Generated") + expect(result.item).toMatchObject({ title: "Manual", autoTitle: "Generated" }) + }) + }) + }) }) diff --git a/src/core/task-title/TaskTitleService.ts b/src/core/task-title/TaskTitleService.ts new file mode 100644 index 0000000000..7cab3d10b1 --- /dev/null +++ b/src/core/task-title/TaskTitleService.ts @@ -0,0 +1,228 @@ +import type { HistoryItem, ProviderSettings, TokenUsage } from "@roo-code/types" + +import { fallbackTitle, generateTaskTitle, type GenerateTaskTitleOptions } from "./generateTaskTitle" + +export const TASK_TITLE_MAX_CONCURRENT = 2 + +export interface TaskTitleSettings { + enabled: boolean + /** Provider profile used for title requests; unset means "use the task's own profile". */ + apiConfigId?: string + customSupportPrompts?: Record +} + +/** The slice of a live task the service needs. */ +export interface TaskTitleTask { + taskId: string + parentTaskId?: string + apiConfiguration: ProviderSettings +} + +export interface TaskTitleServiceDeps { + getHistoryItem(taskId: string): HistoryItem | undefined + setAutoTitle(taskId: string, autoTitle: string): Promise + getSettings(): Promise + /** Resolve a saved provider profile by id; `undefined` when it no longer exists. */ + resolveProfile(apiConfigId: string): Promise + /** Whether the current organization policy allows sending the prompt to this profile. */ + isProfileAllowed(profile: ProviderSettings): Promise + generate?(options: GenerateTaskTitleOptions): Promise + log?(message: string): void +} + +type CancelReason = "user" | "deleted" | "disposed" + +interface Job { + taskId: string + task: TaskTitleTask + controller: AbortController + cancelReason?: CancelReason +} + +/** + * Generates a short display title for new root tasks in the background, after the task's first + * API response. It never blocks the task, never reports errors to the chat, and never touches + * the rate-limit clock. Titles are written only through `setAutoTitle`, which re-checks the + * persisted record under the store lock. + */ +export class TaskTitleService { + private readonly candidates = new Map() + private readonly jobs = new Map() + private readonly queue: Job[] = [] + private running = 0 + private disposed = false + + constructor(private readonly deps: TaskTitleServiceDeps) {} + + /** Register a newly created task. Resumed tasks and subtasks are never candidates. */ + onTaskCreated(task: TaskTitleTask): void { + if (this.disposed || task.parentTaskId || this.deps.getHistoryItem(task.taskId)) { + return + } + // Copy the fields so an unscheduled candidate does not keep the live task alive. + const { taskId, parentTaskId, apiConfiguration } = task + this.candidates.set(taskId, { taskId, parentTaskId, apiConfiguration }) + } + + /** + * Drop a task that left the stack before its first API usage. A title request that is already + * scheduled keeps running, so switching tasks does not lose it. + */ + forget(taskId: string): void { + this.candidates.delete(taskId) + } + + /** Called on every token-usage update; schedules once, after the first real API usage. */ + onTokenUsageUpdated(taskId: string, tokenUsage: Pick): void { + if (tokenUsage.totalTokensIn > 0) { + this.schedule(taskId) + } + } + + schedule(taskId: string): void { + const task = this.candidates.get(taskId) + if (this.disposed || !task) { + return + } + this.candidates.delete(taskId) + + const job: Job = { taskId, task, controller: new AbortController() } + this.jobs.set(taskId, job) + this.queue.push(job) + this.drain() + } + + /** + * "user": stop the request and record the free fallback title. + * "deleted": stop the request and record nothing. + */ + cancel(taskId: string, reason: Exclude): void { + this.candidates.delete(taskId) + + const job = this.jobs.get(taskId) + if (!job || job.cancelReason) { + return + } + job.cancelReason = reason + job.controller.abort() + + const queuedAt = this.queue.indexOf(job) + if (queuedAt >= 0) { + this.queue.splice(queuedAt, 1) + this.jobs.delete(taskId) + if (reason === "user") { + void this.finishQueuedCancellation(job) + } + } + } + + dispose(): void { + this.disposed = true + this.candidates.clear() + this.queue.length = 0 + for (const job of this.jobs.values()) { + job.cancelReason = "disposed" + job.controller.abort() + } + this.jobs.clear() + } + + private drain(): void { + while (this.running < TASK_TITLE_MAX_CONCURRENT) { + const job = this.queue.shift() + if (!job) { + return + } + this.running++ + void this.run(job) + .catch((error: unknown) => this.log(`[TaskTitleService] ${job.taskId}: ${errorMessage(error)}`)) + .finally(() => { + this.running-- + if (this.jobs.get(job.taskId) === job) { + this.jobs.delete(job.taskId) + } + this.drain() + }) + } + } + + private async finishQueuedCancellation(job: Job): Promise { + try { + const prepared = await this.prepare(job) + if (prepared) { + await this.record(job.taskId, fallbackTitle(prepared.prompt)) + } + } catch (error) { + this.log(`[TaskTitleService] ${job.taskId}: ${errorMessage(error)}`) + } + } + + /** Runs the pre-flight checks; `undefined` means nothing should be generated or recorded. */ + private async prepare(job: Job): Promise<{ prompt: string; settings: TaskTitleSettings } | undefined> { + const settings = await this.deps.getSettings() + if (!settings.enabled) { + return undefined + } + + const item = this.deps.getHistoryItem(job.taskId) + if (!item || item.title || item.autoTitle) { + return undefined + } + return { prompt: item.task, settings } + } + + private async run(job: Job): Promise { + const prepared = await this.prepare(job) + if (!prepared || isDropped(job)) { + return + } + const { prompt, settings } = prepared + const taskProfile = job.task.apiConfiguration + + let title: string | undefined + // A failed profile lookup or policy check falls through to the fallback title, like a failed request. + try { + const titleProfile = settings.apiConfigId ? await this.deps.resolveProfile(settings.apiConfigId) : undefined + const profile = titleProfile ?? taskProfile + const rateLimited = (taskProfile.rateLimitSeconds ?? 0) > 0 || (titleProfile?.rateLimitSeconds ?? 0) > 0 + // Checked per request: the organization policy may have changed since the task started. + // Cancellation is re-checked after the await so a cancel during the check sends no request. + if (!rateLimited && (await this.deps.isProfileAllowed(profile)) && !job.cancelReason) { + title = await (this.deps.generate ?? generateTaskTitle)({ + profile, + prompt, + customSupportPrompts: settings.customSupportPrompts, + signal: job.controller.signal, + }) + } + } catch (error) { + this.log(`[TaskTitleService] ${job.taskId}: title request failed: ${errorMessage(error)}`) + } + + if (isDropped(job)) { + return + } + // A late result after a user cancellation is discarded in favor of the free fallback. + await this.record(job.taskId, (!job.controller.signal.aborted && title) || fallbackTitle(prompt)) + } + + private async record(taskId: string, title: string): Promise { + if (this.disposed || !title.trim()) { + return + } + await this.deps.setAutoTitle(taskId, title) + } + + private log(message: string): void { + this.deps.log?.(message) + } +} + +/** Cancelled by deletion or disposal: nothing may be recorded for the task. */ +function isDropped(job: Job): boolean { + return job.cancelReason === "deleted" || job.cancelReason === "disposed" +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/core/task-title/__tests__/TaskTitleService.spec.ts b/src/core/task-title/__tests__/TaskTitleService.spec.ts new file mode 100644 index 0000000000..404231941c --- /dev/null +++ b/src/core/task-title/__tests__/TaskTitleService.spec.ts @@ -0,0 +1,575 @@ +import { providerIdentifiers, type HistoryItem, type ProviderSettings } from "@roo-code/types" + +import { generateTaskTitle, type GenerateTaskTitleOptions } from "../generateTaskTitle" +import { + TASK_TITLE_MAX_CONCURRENT, + TaskTitleService, + type TaskTitleServiceDeps, + type TaskTitleSettings, + type TaskTitleTask, +} from "../TaskTitleService" + +const LONG_PROMPT = "Please refactor the authentication module so that it supports refresh tokens" +const FALLBACK = "Please refactor the authentication module" +const USED = { totalTokensIn: 10 } + +const taskProfile: ProviderSettings = { apiProvider: providerIdentifiers.anthropic } + +interface Deferred { + options: GenerateTaskTitleOptions + resolve(value: string | undefined): void + reject(error: Error): void +} + +function historyItem(id: string, overrides: Partial = {}): HistoryItem { + return { id, number: 1, ts: 1, task: LONG_PROMPT, tokensIn: 0, tokensOut: 0, totalCost: 0, ...overrides } +} + +function createHarness( + options: { + settings?: Partial + items?: HistoryItem[] + deps?: Partial> + } = {}, +) { + const items = new Map((options.items ?? []).map((item) => [item.id, item])) + const settings: TaskTitleSettings = { enabled: true, ...options.settings } + const written: Array<{ taskId: string; title: string }> = [] + const requests: Deferred[] = [] + const profiles = new Map() + const prohibited = new Set() + + const service = new TaskTitleService({ + getHistoryItem: (taskId) => items.get(taskId), + setAutoTitle: async (taskId, title) => { + written.push({ taskId, title }) + }, + getSettings: async () => settings, + resolveProfile: async (id) => profiles.get(id), + isProfileAllowed: async (profile) => !prohibited.has(profile), + generate: (generateOptions) => + new Promise((resolve, reject) => { + requests.push({ options: generateOptions, resolve, reject }) + generateOptions.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }) + }), + ...options.deps, + }) + + const task = (taskId: string, overrides: Partial = {}): TaskTitleTask => ({ + taskId, + apiConfiguration: taskProfile, + ...overrides, + }) + + /** Create a task whose history item exists only after creation, as for a brand-new root task. */ + const startTask = (taskId: string, overrides: Partial = {}, item?: Partial) => { + service.onTaskCreated(task(taskId, overrides)) + items.set(taskId, historyItem(taskId, item)) + } + + return { service, items, settings, written, requests, profiles, prohibited, task, startTask } +} + +/** Node's native AbortSignal.timeout ignores fake timers; route it through setTimeout instead. */ +function useFakeAbortSignalTimeout() { + vi.spyOn(AbortSignal, "timeout").mockImplementation((ms: number) => { + const controller = new AbortController() + setTimeout(() => controller.abort(new DOMException("The operation timed out", "TimeoutError")), ms) + return controller.signal + }) +} + +const flush = async () => { + for (let i = 0; i < 10; i++) await Promise.resolve() +} + +describe("TaskTitleService", () => { + describe("what gets a request", () => { + it("generates and records a title after the first API usage", async () => { + const h = createHarness() + h.startTask("t1") + + h.service.onTokenUsageUpdated("t1", USED) + await flush() + expect(h.requests).toHaveLength(1) + expect(h.requests[0]!.options).toMatchObject({ prompt: LONG_PROMPT, profile: taskProfile }) + + h.requests[0]!.resolve("Refactor auth module") + await flush() + + expect(h.written).toEqual([{ taskId: "t1", title: "Refactor auth module" }]) + }) + + it("ignores usage updates before any input tokens were used", async () => { + const h = createHarness() + h.startTask("t1") + + h.service.onTokenUsageUpdated("t1", { totalTokensIn: 0 }) + await flush() + expect(h.requests).toHaveLength(0) + + h.service.onTokenUsageUpdated("t1", USED) + await flush() + expect(h.requests).toHaveLength(1) + }) + + it("runs once per task however many usage updates arrive", async () => { + const h = createHarness() + h.startTask("t1") + + h.service.onTokenUsageUpdated("t1", USED) + h.service.onTokenUsageUpdated("t1", USED) + await flush() + h.requests[0]!.resolve("Title") + await flush() + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + expect(h.requests).toHaveLength(1) + expect(h.written).toHaveLength(1) + }) + + it("uses the configured title profile instead of the task profile", async () => { + const h = createHarness({ settings: { apiConfigId: "light" } }) + const lightProfile: ProviderSettings = { apiProvider: providerIdentifiers.openai } + h.profiles.set("light", lightProfile) + h.startTask("t1") + + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + expect(h.requests[0]!.options.profile).toBe(lightProfile) + }) + + it("falls back to the task profile when the configured profile no longer exists", async () => { + const h = createHarness({ settings: { apiConfigId: "gone" } }) + h.startTask("t1") + + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + expect(h.requests[0]!.options.profile).toBe(taskProfile) + }) + + it("generates a title for a prompt of 42 characters or fewer", async () => { + const h = createHarness() + h.startTask("t1", {}, { task: "Fix the login bug" }) + + h.service.onTokenUsageUpdated("t1", USED) + await flush() + h.requests[0]!.resolve("Login bug fix") + await flush() + + expect(h.requests[0]!.options.prompt).toBe("Fix the login bug") + expect(h.written).toEqual([{ taskId: "t1", title: "Login bug fix" }]) + }) + + it("passes custom support prompts to the generator", async () => { + const h = createHarness({ settings: { customSupportPrompts: { TASK_TITLE: "x ${userInput}" } } }) + h.startTask("t1") + + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + expect(h.requests[0]!.options.customSupportPrompts).toEqual({ TASK_TITLE: "x ${userInput}" }) + }) + }) + + describe("exclusions", () => { + const cases: Array<{ + name: string + setup(h: ReturnType): void + expectFallback: boolean + }> = [ + { + name: "a subtask", + setup: (h) => h.startTask("t1", { parentTaskId: "parent" }), + expectFallback: false, + }, + { + name: "a resumed task (history item already exists)", + setup: (h) => { + h.items.set("t1", historyItem("t1")) + h.service.onTaskCreated(h.task("t1")) + }, + expectFallback: false, + }, + { + name: "generation disabled", + setup: (h) => { + h.settings.enabled = false + h.startTask("t1") + }, + expectFallback: false, + }, + { + name: "an existing manual title", + setup: (h) => h.startTask("t1", {}, { title: "Mine" }), + expectFallback: false, + }, + { + name: "an existing auto title", + setup: (h) => h.startTask("t1", {}, { autoTitle: "Already" }), + expectFallback: false, + }, + { + name: "a task profile with a rate limit", + setup: (h) => h.startTask("t1", { apiConfiguration: { ...taskProfile, rateLimitSeconds: 5 } }), + expectFallback: true, + }, + ] + + it.each(cases)("sends no request for $name", async ({ setup, expectFallback }) => { + const h = createHarness() + setup(h) + + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + expect(h.requests).toHaveLength(0) + expect(h.written).toEqual(expectFallback ? [{ taskId: "t1", title: FALLBACK }] : []) + }) + + it("records only the fallback when the title profile has a rate limit", async () => { + const h = createHarness({ settings: { apiConfigId: "limited" } }) + h.profiles.set("limited", { apiProvider: providerIdentifiers.openai, rateLimitSeconds: 1 }) + h.startTask("t1") + + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + expect(h.requests).toHaveLength(0) + expect(h.written).toEqual([{ taskId: "t1", title: FALLBACK }]) + }) + }) + + describe("organization allow-list", () => { + it("records only the fallback when the title profile is prohibited", async () => { + const h = createHarness({ settings: { apiConfigId: "blocked" } }) + const blocked: ProviderSettings = { apiProvider: providerIdentifiers.openai } + h.profiles.set("blocked", blocked) + h.prohibited.add(blocked) + h.startTask("t1") + + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + expect(h.requests).toHaveLength(0) + expect(h.written).toEqual([{ taskId: "t1", title: FALLBACK }]) + }) + + it("does not switch to the task profile when the title profile is prohibited", async () => { + const h = createHarness({ settings: { apiConfigId: "blocked" } }) + const blocked: ProviderSettings = { apiProvider: providerIdentifiers.openai } + h.profiles.set("blocked", blocked) + h.prohibited.add(blocked) + h.startTask("t1") + + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + expect(h.requests.some((r) => r.options.profile === taskProfile)).toBe(false) + }) + + it("checks the policy at request time, after the task was created", async () => { + const h = createHarness() + h.startTask("t1") + // The task profile became prohibited after the task started. + h.prohibited.add(taskProfile) + + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + expect(h.requests).toHaveLength(0) + expect(h.written).toEqual([{ taskId: "t1", title: FALLBACK }]) + }) + + it("still requests when the effective profile is allowed", async () => { + const h = createHarness() + h.startTask("t1") + + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + expect(h.requests).toHaveLength(1) + }) + + it.each([ + { name: "profile lookup", deps: { resolveProfile: () => Promise.reject(new Error("profile removed")) } }, + { name: "policy check", deps: { isProfileAllowed: () => Promise.reject(new Error("state unavailable")) } }, + ])("records only the fallback when the $name rejects", async ({ deps }) => { + const h = createHarness({ settings: { apiConfigId: "light" }, deps }) + h.profiles.set("light", { apiProvider: providerIdentifiers.openai }) + h.startTask("t1") + + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + expect(h.requests).toHaveLength(0) + expect(h.written).toEqual([{ taskId: "t1", title: FALLBACK }]) + }) + }) + + describe("results", () => { + it.each([ + ["a provider failure", (d: Deferred) => d.reject(new Error("boom"))], + ["an unusable result", (d: Deferred) => d.resolve(undefined)], + ])("records the fallback title after %s", async (_label, settle) => { + const h = createHarness() + h.startTask("t1") + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + settle(h.requests[0]!) + await flush() + + expect(h.written).toEqual([{ taskId: "t1", title: FALLBACK }]) + }) + + it("still hands the generated title to setAutoTitle when the item vanished meanwhile", async () => { + const h = createHarness() + h.startTask("t1") + h.service.onTokenUsageUpdated("t1", USED) + await flush() + h.items.delete("t1") + + h.requests[0]!.resolve("Title") + await flush() + + // The service delegates the final decision to setAutoTitle (store lock); it still attempts the write. + expect(h.written).toEqual([{ taskId: "t1", title: "Title" }]) + }) + }) + + describe("concurrency", () => { + it("runs at most two requests at once and starts queued ones as slots free up", async () => { + const h = createHarness() + for (const id of ["a", "b", "c"]) { + h.startTask(id) + h.service.onTokenUsageUpdated(id, USED) + } + await flush() + + expect(TASK_TITLE_MAX_CONCURRENT).toBe(2) + expect(h.requests).toHaveLength(2) + + h.requests[0]!.resolve("First") + await flush() + + expect(h.requests).toHaveLength(3) + expect(h.requests.map((r) => r.options.prompt)).toEqual([LONG_PROMPT, LONG_PROMPT, LONG_PROMPT]) + }) + }) + + describe("cancellation", () => { + it("aborts a running request on user cancel and records the fallback", async () => { + const h = createHarness() + h.startTask("t1") + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + h.service.cancel("t1", "user") + await flush() + + expect(h.requests[0]!.options.signal?.aborted).toBe(true) + expect(h.written).toEqual([{ taskId: "t1", title: FALLBACK }]) + }) + + it("discards a late generated title after a user cancel", async () => { + const h = createHarness() + h.startTask("t1") + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + h.service.cancel("t1", "user") + h.requests[0]!.resolve("Too late") + await flush() + + expect(h.written).toEqual([{ taskId: "t1", title: FALLBACK }]) + }) + + it("aborts a running request on delete and records nothing", async () => { + const h = createHarness() + h.startTask("t1") + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + h.service.cancel("t1", "deleted") + h.requests[0]!.resolve("Late") + await flush() + + expect(h.requests[0]!.options.signal?.aborted).toBe(true) + expect(h.written).toEqual([]) + }) + + it.each([ + { reason: "user" as const, written: [{ taskId: "t1", title: FALLBACK }] }, + { reason: "deleted" as const, written: [] }, + ])("sends no request when cancelled ($reason) during the policy check", async ({ reason, written }) => { + let allow: (allowed: boolean) => void = () => {} + const h = createHarness({ + deps: { isProfileAllowed: () => new Promise((resolve) => (allow = resolve)) }, + }) + h.startTask("t1") + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + h.service.cancel("t1", reason) + allow(true) + await flush() + + expect(h.requests).toHaveLength(0) + expect(h.written).toEqual(written) + }) + + it("records the fallback for a queued task cancelled by the user without a request", async () => { + const h = createHarness() + for (const id of ["a", "b", "c"]) { + h.startTask(id) + h.service.onTokenUsageUpdated(id, USED) + } + await flush() + + h.service.cancel("c", "user") + await flush() + + expect(h.requests).toHaveLength(2) + expect(h.written).toEqual([{ taskId: "c", title: FALLBACK }]) + + h.requests[0]!.resolve("A") + await flush() + expect(h.requests).toHaveLength(2) + }) + + it("records nothing for a queued task that is deleted", async () => { + const h = createHarness() + for (const id of ["a", "b", "c"]) { + h.startTask(id) + h.service.onTokenUsageUpdated(id, USED) + } + await flush() + + h.service.cancel("c", "deleted") + h.requests[0]!.resolve("A") + h.requests[1]!.resolve("B") + await flush() + + expect(h.requests).toHaveLength(2) + expect(h.written.map((w) => w.taskId).sort()).toEqual(["a", "b"]) + }) + + it("never schedules a task cancelled by the user before its first API usage", async () => { + const h = createHarness() + h.startTask("t1") + + h.service.cancel("t1", "user") + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + expect(h.requests).toHaveLength(0) + expect(h.written).toEqual([]) + }) + + it("keeps running when the task is merely switched away (no cancel call)", async () => { + const h = createHarness() + h.startTask("t1") + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + // A TaskAborted caused by a task switch or stack removal does not call cancel(). + h.requests[0]!.resolve("Still generated") + await flush() + + expect(h.requests[0]!.options.signal?.aborted).toBe(false) + expect(h.written).toEqual([{ taskId: "t1", title: "Still generated" }]) + }) + + it("drops an unscheduled task that leaves the stack before its first API usage", async () => { + const h = createHarness() + h.startTask("t1") + + h.service.forget("t1") + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + expect(h.requests).toHaveLength(0) + expect(h.written).toEqual([]) + }) + + it("keeps a scheduled request running when the task leaves the stack", async () => { + const h = createHarness() + h.startTask("t1") + h.service.onTokenUsageUpdated("t1", USED) + await flush() + + h.service.forget("t1") + h.requests[0]!.resolve("Still generated") + await flush() + + expect(h.requests[0]!.options.signal?.aborted).toBe(false) + expect(h.written).toEqual([{ taskId: "t1", title: "Still generated" }]) + }) + + it("does not retain the live task object for an unscheduled candidate", () => { + const h = createHarness() + const live = { ...h.task("t1"), extra: "live task state" } + h.service.onTaskCreated(live) + + const stored = h.service["candidates"].get("t1") + expect(stored).not.toBe(live) + expect(stored).toEqual({ taskId: "t1", parentTaskId: undefined, apiConfiguration: taskProfile }) + }) + + it("aborts everything and writes nothing after dispose", async () => { + const h = createHarness() + for (const id of ["a", "b", "c"]) { + h.startTask(id) + h.service.onTokenUsageUpdated(id, USED) + } + await flush() + + h.service.dispose() + h.requests[0]!.resolve("A") + await flush() + + expect(h.requests.every((r) => r.options.signal?.aborted)).toBe(true) + expect(h.requests).toHaveLength(2) + expect(h.written).toEqual([]) + }) + }) + + describe("a provider that ignores the abort signal", () => { + beforeEach(() => { + vi.useFakeTimers() + useFakeAbortSignalTimeout() + }) + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it("records the fallback once the 15 second limit passes", async () => { + const written: string[] = [] + const items = new Map() + const service = new TaskTitleService({ + getHistoryItem: (taskId) => items.get(taskId), + setAutoTitle: async (_taskId, title) => { + written.push(title) + }, + getSettings: async () => ({ enabled: true }), + resolveProfile: async () => undefined, + isProfileAllowed: async () => true, + generate: (options) => generateTaskTitle({ ...options, complete: () => new Promise(() => {}) }), + }) + service.onTaskCreated({ taskId: "t1", apiConfiguration: taskProfile }) + items.set("t1", historyItem("t1")) + service.onTokenUsageUpdated("t1", USED) + + await vi.advanceTimersByTimeAsync(14_000) + expect(written).toEqual([]) + + await vi.advanceTimersByTimeAsync(2_000) + expect(written).toEqual([FALLBACK]) + }) + }) +}) diff --git a/src/core/task-title/__tests__/generateTaskTitle.spec.ts b/src/core/task-title/__tests__/generateTaskTitle.spec.ts new file mode 100644 index 0000000000..3c7c2ba904 --- /dev/null +++ b/src/core/task-title/__tests__/generateTaskTitle.spec.ts @@ -0,0 +1,193 @@ +import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" + +import { + buildTitleInput, + cleanTitle, + fallbackTitle, + generateTaskTitle, + TASK_TITLE_FALLBACK_CHARS, + TASK_TITLE_MAX_CHARS, + TASK_TITLE_MAX_INPUT_CHARS, + type CompleteFn, +} from "../generateTaskTitle" + +const PROMPT = "Please refactor the authentication module so that it supports refresh tokens" +const profile: ProviderSettings = { apiProvider: providerIdentifiers.anthropic } + +/** Node's native AbortSignal.timeout ignores fake timers; route it through setTimeout instead. */ +function useFakeAbortSignalTimeout() { + vi.spyOn(AbortSignal, "timeout").mockImplementation((ms: number) => { + const controller = new AbortController() + setTimeout(() => controller.abort(new DOMException("The operation timed out", "TimeoutError")), ms) + return controller.signal + }) +} + +describe("cleanTitle", () => { + it.each([ + ["plain title", "Refactor auth module", "Refactor auth module"], + ["surrounding quotes", '"Refactor auth module"', "Refactor auth module"], + ["curly quotes", "“Refactor auth module”", "Refactor auth module"], + ["backticks", "`Refactor auth module`", "Refactor auth module"], + ["Title: prefix", "Title: Refactor auth module", "Refactor auth module"], + ["title prefix lowercase with quotes", 'title: "Refactor auth module"', "Refactor auth module"], + ["markdown heading and bold", "## **Refactor auth module**", "Refactor auth module"], + ["trailing period", "Refactor auth module.", "Refactor auth module"], + ["trailing punctuation run", "Refactor auth module?!…", "Refactor auth module"], + ["multiple lines", "\n\nRefactor auth module\nThis explains why", "Refactor auth module"], + ["closed think block", "hmm\nmore\nRefactor auth module", "Refactor auth module"], + ["closed thinking block", "xRefactor auth module", "Refactor auth module"], + ["Korean title", "인증 모듈 리팩터링.", "인증 모듈 리팩터링"], + ["inline bold", "Fix **auth** bug", "Fix auth bug"], + ["underscore emphasis", "_Refactor_ auth module", "Refactor auth module"], + ["snake_case identifier", "Rename user_id to account_id", "Rename user_id to account_id"], + ["dunder identifier", "Fix __init__.py import", "Fix __init__.py import"], + ["bold before a sentence period", "Fix **auth** bug.", "Fix auth bug"], + ["bold wrapping the whole title then a period", "**Refactor auth module**.", "Refactor auth module"], + ["multiplication", "Compute a*b*c totals", "Compute a*b*c totals"], + ])("cleans %s", (_label, raw, expected) => { + expect(cleanTitle(raw, PROMPT)).toBe(expected) + }) + + it.each([ + ["empty", ""], + ["whitespace", " \n\t "], + ["only a think block", "reasoning"], + ["unclosed think tag swallowing the rest", "reasoning that never ends\nRefactor auth module"], + ["only quotes and punctuation", '"."'], + ["same as the prompt", PROMPT], + ["same as the prompt after whitespace normalization", ` ${PROMPT.replace(/ /g, " ")} `], + ])("returns undefined for %s", (_label, raw) => { + expect(cleanTitle(raw, PROMPT)).toBeUndefined() + }) + + it("keeps text before an unclosed think tag", () => { + expect(cleanTitle("Refactor auth module\noops", PROMPT)).toBe("Refactor auth module") + }) + + it("truncates to 50 characters without splitting surrogate pairs", () => { + const raw = "😀".repeat(TASK_TITLE_MAX_CHARS + 10) + const result = cleanTitle(raw, PROMPT) + + expect(Array.from(result ?? "")).toHaveLength(TASK_TITLE_MAX_CHARS) + expect(result).toBe("😀".repeat(TASK_TITLE_MAX_CHARS)) + }) +}) + +describe("fallbackTitle", () => { + it.each([ + ["short prompt unchanged", "Fix the login bug", "Fix the login bug"], + ["whitespace and newlines normalized", "\n\n Fix the\tlogin\nbug ", "Fix the login bug"], + ["exactly 42 characters unchanged", "a".repeat(42), "a".repeat(42)], + ["long prompt cut to its first 42 characters", PROMPT, "Please refactor the authentication module"], + [ + "mentions and URLs kept verbatim", + "Explain @/src/core/task/Task.ts and https://x.io", + "Explain @/src/core/task/Task.ts and https:", + ], + ["empty", "", ""], + ])("%s", (_label, prompt, expected) => { + expect(fallbackTitle(prompt)).toBe(expected) + }) + + it("cuts at 42 code points without splitting surrogate pairs", () => { + expect(fallbackTitle("😀".repeat(80))).toBe("😀".repeat(TASK_TITLE_FALLBACK_CHARS)) + }) +}) + +describe("buildTitleInput", () => { + it("caps the input at 1,000 characters", () => { + expect(Array.from(buildTitleInput("x".repeat(5_000)))).toHaveLength(TASK_TITLE_MAX_INPUT_CHARS) + }) + + it("keeps only the beginning of long code blocks", () => { + const body = Array.from({ length: 50 }, (_, i) => `line${i}`).join("\n") + const result = buildTitleInput(`Look at this:\n\`\`\`ts\n${body}\n\`\`\`\nthanks`) + + expect(result).toContain("line0") + expect(result).toContain("line7") + expect(result).not.toContain("line20") + expect(result).toContain("thanks") + }) + + it("leaves short code blocks intact", () => { + const text = "```ts\nconst a = 1\n```" + expect(buildTitleInput(text)).toBe(text) + }) +}) + +describe("generateTaskTitle", () => { + it("sends the TASK_TITLE prompt with a merged signal and the 15s timeout, and cleans the result", async () => { + const complete = vi.fn().mockResolvedValue('"Refactor auth module."') + + const title = await generateTaskTitle({ profile, prompt: PROMPT, complete }) + + expect(title).toBe("Refactor auth module") + expect(complete).toHaveBeenCalledTimes(1) + const [usedProfile, request, options] = complete.mock.calls[0]! + expect(usedProfile).toBe(profile) + expect(request).toContain(PROMPT) + expect(options?.timeoutMs).toBe(15_000) + expect(options?.abortSignal).toBeInstanceOf(AbortSignal) + expect(options).not.toHaveProperty("taskId") + }) + + it("uses a custom TASK_TITLE support prompt", async () => { + const complete = vi.fn().mockResolvedValue("Title") + + await generateTaskTitle({ + profile, + prompt: PROMPT, + customSupportPrompts: { TASK_TITLE: "CUSTOM: ${userInput}" }, + complete, + }) + + expect(complete.mock.calls[0]![1]).toBe(`CUSTOM: ${PROMPT}`) + }) + + it("returns undefined when the model output is unusable", async () => { + const complete = vi.fn().mockResolvedValue("nothing") + + await expect(generateTaskTitle({ profile, prompt: PROMPT, complete })).resolves.toBeUndefined() + }) + + it("propagates provider failures", async () => { + const complete = vi.fn().mockRejectedValue(new Error("boom")) + + await expect(generateTaskTitle({ profile, prompt: PROMPT, complete })).rejects.toThrow("boom") + }) + + it("rejects promptly when the caller aborts, even if the provider ignores the signal", async () => { + const controller = new AbortController() + const complete = vi.fn().mockReturnValue(new Promise(() => {})) + + const pending = generateTaskTitle({ profile, prompt: PROMPT, complete, signal: controller.signal }) + controller.abort() + + await expect(pending).rejects.toMatchObject({ name: "AbortError" }) + }) + + describe("timeout", () => { + beforeEach(() => { + vi.useFakeTimers() + useFakeAbortSignalTimeout() + }) + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it("settles when the timeout elapses even if the provider ignores the signal", async () => { + const complete = vi.fn().mockReturnValue(new Promise(() => {})) + + const pending = generateTaskTitle({ profile, prompt: PROMPT, complete, timeoutMs: 15_000 }) + const outcome = pending.then( + () => "resolved", + (error: Error) => error.name, + ) + await vi.advanceTimersByTimeAsync(15_001) + + await expect(outcome).resolves.toBe("AbortError") + }) + }) +}) diff --git a/src/core/task-title/generateTaskTitle.ts b/src/core/task-title/generateTaskTitle.ts new file mode 100644 index 0000000000..5a54a6186b --- /dev/null +++ b/src/core/task-title/generateTaskTitle.ts @@ -0,0 +1,138 @@ +import type { ProviderSettings } from "@roo-code/types" + +import { supportPrompt } from "../../shared/support-prompt" +import { mergeAbortSignalAndTimeout, settleOnAbort } from "../../api/providers/utils/abort-signal" +import { singleCompletionHandler } from "../../utils/single-completion-handler" +import type { CompletePromptOptions } from "../../api" + +export const TASK_TITLE_TIMEOUT_MS = 15_000 +export const TASK_TITLE_MAX_INPUT_CHARS = 1_000 +export const TASK_TITLE_MAX_CHARS = 50 +export const TASK_TITLE_FALLBACK_CHARS = 42 + +const CODE_BLOCK_MAX_LINES = 8 + +export function normalizeWhitespace(text: string): string { + return text.replace(/\s+/g, " ").trim() +} + +function truncateCodeBlocks(text: string): string { + return text.replace(/```[^\n]*\n([\s\S]*?)(```|$)/g, (block: string, body: string) => { + const lines = body.split("\n") + if (lines.length <= CODE_BLOCK_MAX_LINES) { + return block + } + return "```\n" + lines.slice(0, CODE_BLOCK_MAX_LINES).join("\n") + "\n...\n```" + }) +} + +/** The prompt text sent to the model: code blocks shortened, then capped at 1,000 characters. */ +export function buildTitleInput(prompt: string): string { + return Array.from(truncateCodeBlocks(prompt)).slice(0, TASK_TITLE_MAX_INPUT_CHARS).join("") +} + +const QUOTE_PAIRS: Array<[string, string]> = [ + ['"', '"'], + ["'", "'"], + ["`", "`"], + ["“", "”"], + ["‘", "’"], + ["「", "」"], + ["『", "』"], +] + +function stripSurroundingQuotes(text: string): string { + let current = text + for (let changed = true; changed;) { + changed = false + for (const [open, close] of QUOTE_PAIRS) { + if (current.length >= 2 && current.startsWith(open) && current.endsWith(close)) { + current = current.slice(open.length, current.length - close.length).trim() + changed = true + } + } + } + return current +} + +/** + * Remove markdown emphasis markers that wrap text, leaving markers inside identifiers such as + * `snake_case` or `a*b` untouched. + */ +function stripEmphasis(text: string): string { + return text.replace( + /(^|[^\p{L}\p{N}*_])(\*{1,3}|_{1,3})(?=\S)(.+?)(?<=\S)\2(?![\p{L}\p{N}*_]|\.[\p{L}\p{N}])/gu, + "$1$3", + ) +} + +/** + * Turn raw model output into a display title. Returns `undefined` when nothing usable remains or + * the result merely repeats the prompt, so callers fall back to {@link fallbackTitle}. + */ +export function cleanTitle(raw: string, originalPrompt: string): string | undefined { + const withoutThinking = raw + .replace(/<(think|thinking)>[\s\S]*?<\/\1>/gi, "") + .replace(/<(think|thinking)>[\s\S]*$/i, "") + + const firstLine = withoutThinking + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) + if (!firstLine) { + return undefined + } + + let title = stripEmphasis(firstLine) + .replace(/^(?:[#>*\-\s]+)/, "") + .replace(/^(?:title|subject|제목)\s*[::]\s*/i, "") + .trim() + title = stripSurroundingQuotes(title) + title = title.replace(/[\s.。!?…,;:,;:]+$/u, "").trim() + title = stripSurroundingQuotes(title) + + title = normalizeWhitespace(title) + if (!title || title === normalizeWhitespace(originalPrompt)) { + return undefined + } + return Array.from(title).slice(0, TASK_TITLE_MAX_CHARS).join("").trim() +} + +/** First 42 characters of the whitespace-normalized prompt: the free title used when generation is skipped or fails. */ +export function fallbackTitle(prompt: string): string { + return Array.from(normalizeWhitespace(prompt)).slice(0, TASK_TITLE_FALLBACK_CHARS).join("").trim() +} + +export type CompleteFn = ( + apiConfiguration: ProviderSettings, + promptText: string, + options?: CompletePromptOptions, +) => Promise + +export interface GenerateTaskTitleOptions { + profile: ProviderSettings + prompt: string + customSupportPrompts?: Record + signal?: AbortSignal + timeoutMs?: number + complete?: CompleteFn +} + +/** + * Ask the model for a title. Rejects on provider failure, timeout, or abort; resolves `undefined` + * when the model output is unusable. + */ +export async function generateTaskTitle(options: GenerateTaskTitleOptions): Promise { + const { profile, prompt, customSupportPrompts, signal, timeoutMs = TASK_TITLE_TIMEOUT_MS } = options + const complete = options.complete ?? singleCompletionHandler + + const request = supportPrompt.create("TASK_TITLE", { userInput: buildTitleInput(prompt) }, customSupportPrompts) + const merged = mergeAbortSignalAndTimeout(signal, timeoutMs) + + const raw = await settleOnAbort( + complete(profile, request, { abortSignal: merged, timeoutMs }), + merged, + "task title", + ) + return cleanTitle(raw, prompt) +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6cec3bdbe7..7e6ca24b94 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -67,6 +67,7 @@ import { DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, + DEFAULT_TASK_TITLE_GENERATION_ENABLED, ORGANIZATION_ALLOW_ALL, DEFAULT_MODES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, @@ -153,6 +154,7 @@ import { } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" import { readFileWithMissingRetry } from "../task-persistence/readFileWithMissingRetry" +import { TaskTitleService } from "../task-title/TaskTitleService" import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { omitOriginalContentFromExtensionMessage } from "./stripOriginalContent" @@ -327,6 +329,7 @@ export class ClineProvider private recentTasksCache?: string[] public readonly taskHistoryStore: TaskHistoryStore + private readonly taskTitleService: TaskTitleService private taskHistoryStoreInitialized = false public static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds private providerProfileMutationQueue = Promise.resolve() @@ -448,6 +451,34 @@ export class ClineProvider this.log(`Failed to initialize TaskHistoryStore: ${error}`) }) + this.taskTitleService = new TaskTitleService({ + getHistoryItem: (taskId) => this.taskHistoryStore.get(taskId), + setAutoTitle: (taskId, autoTitle) => this.setAutoTitle(taskId, autoTitle), + getSettings: async () => { + const { taskTitleGenerationEnabled, taskTitleApiConfigId, customSupportPrompts } = await this.getState({ + includeTaskHistory: false, + }) + return { + enabled: taskTitleGenerationEnabled ?? DEFAULT_TASK_TITLE_GENERATION_ENABLED, + apiConfigId: taskTitleApiConfigId, + customSupportPrompts, + } + }, + resolveProfile: async (apiConfigId) => { + const { listApiConfigMeta = [] } = await this.getState({ includeTaskHistory: false }) + if (!listApiConfigMeta.some(({ id }) => id === apiConfigId)) { + return undefined + } + const { name: _name, ...profile } = await this.providerSettingsManager.getProfile({ id: apiConfigId }) + return profile.apiProvider ? profile : undefined + }, + isProfileAllowed: async (profile) => { + const { organizationAllowList } = await this.getState({ includeTaskHistory: false }) + return ProfileValidator.isProfileAllowed(profile, organizationAllowList) + }, + log: (message) => this.log(message), + }) + // Start configuration loading (which might trigger indexing) in the background. // Don't await, allowing activation to continue immediately. @@ -485,6 +516,7 @@ export class ClineProvider // We do something fairly similar for the IPC-based API. this.taskCreationCallback = (instance: Task) => { this.emit(RooCodeEventName.TaskCreated, instance) + this.taskTitleService.onTaskCreated(instance) // Create named listener functions so we can remove them later. const onTaskStarted = () => this.emit(RooCodeEventName.TaskStarted, instance.taskId) @@ -506,7 +538,12 @@ export class ClineProvider } this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage) } - const onTaskAborted = () => this.emit(RooCodeEventName.TaskAborted, instance.taskId) + const onTaskAborted = () => { + if (instance.abortReason === "user_cancelled") { + this.taskTitleService.cancel(instance.taskId, "user") + } + this.emit(RooCodeEventName.TaskAborted, instance.taskId) + } const onTaskFocused = () => this.emit(RooCodeEventName.TaskFocused, instance.taskId) const onTaskUnfocused = () => this.emit(RooCodeEventName.TaskUnfocused, instance.taskId) const onTaskActive = (taskId: string) => this.emit(RooCodeEventName.TaskActive, taskId) @@ -517,8 +554,10 @@ export class ClineProvider const onTaskUnpaused = (taskId: string) => this.emit(RooCodeEventName.TaskUnpaused, taskId) const onTaskSpawned = (taskId: string) => this.emit(RooCodeEventName.TaskSpawned, taskId) const onTaskUserMessage = (taskId: string) => this.emit(RooCodeEventName.TaskUserMessage, taskId) - const onTaskTokenUsageUpdated = (taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage) => + const onTaskTokenUsageUpdated = (taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage) => { + this.taskTitleService.onTokenUsageUpdated(taskId, tokenUsage) this.emit(RooCodeEventName.TaskTokenUsageUpdated, taskId, tokenUsage, toolUsage) + } // Attach the listeners. instance.on(RooCodeEventName.TaskStarted, onTaskStarted) @@ -552,6 +591,7 @@ export class ClineProvider () => instance.off(RooCodeEventName.TaskUnpaused, onTaskUnpaused), () => instance.off(RooCodeEventName.TaskSpawned, onTaskSpawned), () => instance.off(RooCodeEventName.TaskTokenUsageUpdated, onTaskTokenUsageUpdated), + () => this.taskTitleService.forget(instance.taskId), ]) } } @@ -1000,6 +1040,7 @@ export class ClineProvider this.skillsManager = undefined await this.marketplaceManager?.cleanup() this.customModesManager?.dispose() + this.taskTitleService.dispose() this.taskHistoryStore.dispose() this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) @@ -2831,6 +2872,19 @@ export class ClineProvider } } + /* + * Records a generated title unless the task already has one. The store decides under its lock + * against the persisted record, so a deleted or already-titled task is left untouched. + */ + async setAutoTitle(id: string, autoTitle: string) { + const { item } = await this.taskHistoryStore.setAutoTitleIfAbsent(id, autoTitle) + this.recentTasksCache = undefined + + if (item && this.isViewLaunched) { + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: item }) + } + } + /* Condenses a task's message history to use fewer tokens. */ async condenseTaskContext(taskId: string) { const task = this.taskRegistry.getById(taskId) @@ -2879,6 +2933,10 @@ export class ClineProvider } } + for (const taskId of allIdsToDelete) { + this.taskTitleService.cancel(taskId, "deleted") + } + // Delete all tasks from state in one batch await this.taskHistoryStore.deleteMany(allIdsToDelete) this.recentTasksCache = undefined @@ -2922,6 +2980,7 @@ export class ClineProvider } async deleteTaskFromState(id: string) { + this.taskTitleService.cancel(id, "deleted") await this.taskHistoryStore.delete(id) this.recentTasksCache = undefined @@ -3183,6 +3242,8 @@ export class ClineProvider includeDiagnosticMessages, maxDiagnosticMessages, includeTaskHistoryInEnhance, + taskTitleGenerationEnabled, + taskTitleApiConfigId, includeCurrentTime, includeCurrentCost, maxGitStatusFiles, @@ -3388,6 +3449,8 @@ export class ClineProvider includeDiagnosticMessages: includeDiagnosticMessages ?? true, maxDiagnosticMessages: maxDiagnosticMessages ?? 50, includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true, + taskTitleGenerationEnabled: taskTitleGenerationEnabled ?? DEFAULT_TASK_TITLE_GENERATION_ENABLED, + taskTitleApiConfigId, includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, maxGitStatusFiles: maxGitStatusFiles ?? 0, @@ -3620,6 +3683,8 @@ export class ClineProvider includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true, + taskTitleGenerationEnabled: stateValues.taskTitleGenerationEnabled ?? DEFAULT_TASK_TITLE_GENERATION_ENABLED, + taskTitleApiConfigId: stateValues.taskTitleApiConfigId, includeCurrentTime: stateValues.includeCurrentTime ?? true, includeCurrentCost: stateValues.includeCurrentCost ?? true, maxGitStatusFiles: stateValues.maxGitStatusFiles ?? 0, @@ -3637,9 +3702,10 @@ export class ClineProvider * Updates a task in the task history and optionally broadcasts the updated history to the webview. * Now delegates to TaskHistoryStore for per-task file persistence. * - * This writer never owns the `title` field: `title` is written only by the rename path - * (`renameTaskWithId` via `taskHistoryStore.atomicReadAndUpdate`). Callers here build items - * from snapshots read before taking the store lock, so any title they carry can be stale. + * This writer never owns the `title` or `autoTitle` fields: `title` is written only by the rename + * path (`renameTaskWithId` via `taskHistoryStore.atomicReadAndUpdate`) and `autoTitle` only by + * `setAutoTitle`. Callers here build items from snapshots read before taking the store lock, + * so any title they carry can be stale. * * @param item The history item to update or add * @param options.broadcast Whether to broadcast the updated history to the webview (default: true) @@ -3648,9 +3714,9 @@ export class ClineProvider async updateTaskHistory(item: HistoryItem, options: { broadcast?: boolean } = {}): Promise { const { broadcast = true } = options - // Drop the snapshot's title so upsertCore preserves the value held under the store lock - // instead of treating a stale title as a changed field and reverting a concurrent rename. - const { title: _title, ...itemWithoutTitle } = item + // Drop the snapshot's titles so upsertCore preserves the values held under the store lock + // instead of treating a stale title as a changed field and reverting a concurrent write. + const { title: _title, autoTitle: _autoTitle, ...itemWithoutTitle } = item const history = await this.taskHistoryStore.upsert(itemWithoutTitle) this.recentTasksCache = undefined diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 464889418e..208c0c8a8b 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -4073,6 +4073,29 @@ describe("ClineProvider", () => { }) }) + test.each([ + ["true", true, true], + ["false", false, false], + ["unset", undefined, true], + ])( + "task title generation setting %s round-trips through getState and the webview state", + async (_label, saved, expected) => { + await provider.contextProxy.setValue("taskTitleGenerationEnabled", saved) + await provider.contextProxy.setValue( + "taskTitleApiConfigId", + saved === undefined ? undefined : "title-profile", + ) + + const state = await provider.getState() + const webviewState = await provider.getStateToPostToWebview({ includeTaskHistory: false }) + + expect(state.taskTitleGenerationEnabled).toBe(expected) + expect(webviewState.taskTitleGenerationEnabled).toBe(expected) + expect(state.taskTitleApiConfigId).toBe(saved === undefined ? undefined : "title-profile") + expect(webviewState.taskTitleApiConfigId).toBe(saved === undefined ? undefined : "title-profile") + }, + ) + test("getState returns the saved destructive command guard setting", async () => { await provider.contextProxy.setValue("destructiveCommandGuardEnabled", true) diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 7b6223e2e2..d1068ab3bf 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -11,6 +11,7 @@ import { ContextProxy } from "../../config/ContextProxy" import { Task } from "../../task/Task" import { ProfileValidator } from "../../../shared/ProfileValidator" import { ClineProvider } from "../ClineProvider" +import { safeWriteJson } from "../../../utils/safeWriteJson" // Mock setup vi.mock("p-wait-for", () => ({ @@ -596,6 +597,79 @@ describe("ClineProvider Task History Synchronization", () => { }) }) + describe("autoTitle", () => { + const getStored = (id: string) => provider.taskHistoryStore.get(id) + + beforeEach(() => { + // fs is mocked in this suite, so serve the persisted record from the store cache. + vi.mocked(safeWriteJson).mockImplementation(async (_path, data, options) => { + // Apply the merge callback against the cached record, as the real writer does against disk. + const existing = provider.taskHistoryStore.get((data as HistoryItem).id) ?? null + options?.merge?.(existing, data) + }) + Reflect.set(provider.taskHistoryStore, "readTaskFileForAutoTitle", async (id: string) => { + const cached = provider.taskHistoryStore.get(id) + return cached ? { kind: "found", item: structuredClone(cached) } : { kind: "missing" } + }) + }) + + afterEach(() => { + vi.mocked(safeWriteJson).mockResolvedValue(undefined) + }) + + it("does not let a stale snapshot overwrite or clear a stored autoTitle", async () => { + await provider.updateTaskHistory(createHistoryItem({ id: "task-auto", task: "Long prompt" }), { + broadcast: false, + }) + await provider.setAutoTitle("task-auto", "Generated title") + + await provider.updateTaskHistory( + { ...createHistoryItem({ id: "task-auto", task: "Long prompt" }), autoTitle: "Stale", tokensIn: 5 }, + { broadcast: false }, + ) + await provider.updateTaskHistory(createHistoryItem({ id: "task-auto", task: "Long prompt" }), { + broadcast: false, + }) + + expect(getStored("task-auto")).toMatchObject({ autoTitle: "Generated title" }) + }) + + it("does not let updateTaskHistory write an autoTitle", async () => { + await provider.updateTaskHistory( + { ...createHistoryItem({ id: "task-new", task: "New task" }), autoTitle: "Sneaky" }, + { + broadcast: false, + }, + ) + + expect(getStored("task-new")?.autoTitle).toBeUndefined() + }) + + it("setAutoTitle broadcasts the updated item", async () => { + await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = true + await provider.updateTaskHistory(createHistoryItem({ id: "task-bc", task: "Long prompt" })) + mockPostMessage.mockClear() + + await provider.setAutoTitle("task-bc", "Generated") + + const updates = findCallsByType(mockPostMessage.mock.calls, "taskHistoryItemUpdated") + expect(updates.at(-1)?.[0].taskHistoryItem).toMatchObject({ id: "task-bc", autoTitle: "Generated" }) + }) + + it("setAutoTitle keeps the first title and ignores unknown tasks", async () => { + await provider.updateTaskHistory(createHistoryItem({ id: "task-once", task: "Long prompt" }), { + broadcast: false, + }) + await provider.setAutoTitle("task-once", "First") + await provider.setAutoTitle("task-once", "Second") + await provider.setAutoTitle("missing-task", "Nope") + + expect(getStored("task-once")?.autoTitle).toBe("First") + expect(getStored("missing-task")).toBeUndefined() + }) + }) + describe("renameTaskWithId", () => { const getStored = (id: string) => provider.taskHistoryStore.get(id) @@ -1115,4 +1189,96 @@ describe("ClineProvider Task History Synchronization", () => { expect(listener).toHaveBeenCalledWith("child-task", {}, {}) }) }) + + describe("taskCreationCallback — task title wiring", () => { + function makeTitleTask(taskId: string, abortReason?: string) { + const listeners = new Map unknown>>() + return { + taskId, + abortReason, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + on: (event: string, fn: (...args: unknown[]) => unknown) => { + listeners.set(event, [...(listeners.get(event) ?? []), fn]) + }, + off: (event: string, fn: (...args: unknown[]) => unknown) => { + listeners.set( + event, + (listeners.get(event) ?? []).filter((listener) => listener !== fn), + ) + }, + emit: async (event: string, ...args: unknown[]) => { + await Promise.all((listeners.get(event) ?? []).map((fn) => Promise.resolve(fn(...args)))) + }, + } + } + + function register(fakeTask: ReturnType) { + // Double cast: taskCreationCallback reads only on/off/taskId/abortReason/apiConfiguration from the fake. + const task = fakeTask as unknown as Task + provider["taskCreationCallback"](task) + return task + } + + it("registers a new task as a title candidate", () => { + const onTaskCreated = vi.spyOn(provider["taskTitleService"], "onTaskCreated") + const fakeTask = makeTitleTask("title-created") + + register(fakeTask) + + expect(onTaskCreated).toHaveBeenCalledExactlyOnceWith(fakeTask) + }) + + it.each([ + { totalTokensIn: 0, scheduled: false }, + { totalTokensIn: 12, scheduled: true }, + ])( + "schedules a title only after real input usage ($totalTokensIn tokens in)", + async ({ totalTokensIn, scheduled }) => { + const schedule = vi.spyOn(provider["taskTitleService"], "schedule").mockImplementation(() => {}) + const fakeTask = makeTitleTask("title-usage") + register(fakeTask) + + await fakeTask.emit(RooCodeEventName.TaskTokenUsageUpdated, "title-usage", { totalTokensIn }, {}) + + if (scheduled) { + expect(schedule).toHaveBeenCalledExactlyOnceWith("title-usage") + } else { + expect(schedule).not.toHaveBeenCalled() + } + }, + ) + + it.each([ + { abortReason: "user_cancelled", cancelled: true }, + { abortReason: "streaming_failed", cancelled: false }, + { abortReason: undefined, cancelled: false }, + ])( + "cancels the title request on abort only for a user cancel ($abortReason)", + async ({ abortReason, cancelled }) => { + const cancel = vi.spyOn(provider["taskTitleService"], "cancel") + const forget = vi.spyOn(provider["taskTitleService"], "forget") + const fakeTask = makeTitleTask("title-abort", abortReason) + register(fakeTask) + + await fakeTask.emit(RooCodeEventName.TaskAborted) + + if (cancelled) { + expect(cancel).toHaveBeenCalledExactlyOnceWith("title-abort", "user") + } else { + expect(cancel).not.toHaveBeenCalled() + } + expect(forget).not.toHaveBeenCalled() + }, + ) + + it("forgets an unscheduled candidate when the task's listeners are removed", () => { + const forget = vi.spyOn(provider["taskTitleService"], "forget") + const task = register(makeTitleTask("title-evicted")) + + for (const cleanup of provider["taskEventListeners"].get(task) ?? []) cleanup() + + expect(forget).toHaveBeenCalledExactlyOnceWith("title-evicted") + expect(provider["taskTitleService"]["candidates"].has("title-evicted")).toBe(false) + }) + }) }) diff --git a/src/shared/support-prompt.ts b/src/shared/support-prompt.ts index 6fe48fa364..8201705292 100644 --- a/src/shared/support-prompt.ts +++ b/src/shared/support-prompt.ts @@ -50,6 +50,7 @@ type SupportPromptType = | "TERMINAL_FIX" | "TERMINAL_EXPLAIN" | "NEW_TASK" + | "TASK_TITLE" const supportPromptConfigs: Record = { ENHANCE: { @@ -246,6 +247,12 @@ Please provide: NEW_TASK: { template: `\${userInput}`, }, + TASK_TITLE: { + template: `Write a short title (5-10 words) for the task described in the user prompt below. Use the same language as the user prompt. Reply with only the title - no quotes, no trailing punctuation, no explanations. + +User prompt: +\${userInput}`, + }, } as const export const supportPrompt = { diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index b95e59b733..99ed095916 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -468,6 +468,10 @@ export const stories: Record = { const { UISettingsStory } = await import("@/components/settings/__tests__/UISettings.visual.fixture") return }, + "prompts-settings": async () => { + const { PromptsSettingsStory } = await import("@/components/settings/__tests__/PromptsSettings.visual.fixture") + return + }, "experimental-settings": async () => { const { ExperimentalSettingsStory } = await import("@/components/settings/__tests__/ExperimentalSettings.visual.fixture") diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index e203711c25..5eb7f4038d 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -69,7 +69,7 @@ const TaskHeader = ({ const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) const [isRenaming, setIsRenaming] = useState(false) - const customTitle = currentTaskItem?.title + const customTitle = currentTaskItem?.title || currentTaskItem?.autoTitle const textContainerRef = useRef(null) const textRef = useRef(null) diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 4065f4a80c..11ae504274 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -38,7 +38,7 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ // Create a variable to hold the mock state const mockExtensionState: { apiConfiguration: ProviderSettings - currentTaskItem: { id: string; task?: string; title?: string } | null + currentTaskItem: { id: string; task?: string; title?: string; autoTitle?: string } | null clineMessages: any[] taskHistory: any[] timeFormat?: "auto" | "12h" | "24h" @@ -368,6 +368,17 @@ describe("TaskHeader", () => { expect(screen.queryByText("Test task")).not.toBeInTheDocument() }) + it.each([ + ["the custom title over the generated title", "My title", "Generated", "My title"], + ["the generated title without a custom title", undefined, "Generated", "Generated"], + ])("shows %s in the collapsed header", (_label, title, autoTitle, expected) => { + mockExtensionState.currentTaskItem = { id: "test-task-id", task: "Test task", title, autoTitle } + renderTaskHeader() + + expect(screen.getByText(expected)).toBeInTheDocument() + expect(screen.queryByText("Test task")).not.toBeInTheDocument() + }) + it.each([ ["the custom title", "My title", "My title"], ["the default label without a custom title", undefined, "chat:task.title"], diff --git a/webview-ui/src/components/history/TaskTitleEditor.tsx b/webview-ui/src/components/history/TaskTitleEditor.tsx index c02e044311..20ff975666 100644 --- a/webview-ui/src/components/history/TaskTitleEditor.tsx +++ b/webview-ui/src/components/history/TaskTitleEditor.tsx @@ -9,7 +9,7 @@ import { vscode } from "@/utils/vscode" import { getTaskDisplayTitle } from "./types" type TaskTitleEditorProps = { - item: Pick + item: Pick onDone: () => void className?: string } @@ -29,7 +29,12 @@ export const TaskTitleEditor = ({ item, onDone, className }: TaskTitleEditorProp const save = useCallback(() => { const normalized = normalizeTitle(value) - const nextTitle = normalized && normalized !== normalizeTitle(item.task) ? normalized : undefined + // The original prompt and the generated title are the fallbacks shown when no custom title is set, + // so saving either one unchanged must not turn it into a manual title. + const isFallback = + normalized === normalizeTitle(item.task) || + (item.autoTitle !== undefined && normalized === normalizeTitle(item.autoTitle)) + const nextTitle = normalized && !isFallback ? normalized : undefined if (nextTitle !== item.title) { vscode.postMessage({ type: "renameTaskWithId", taskId: item.id, text: nextTitle ?? "" }) diff --git a/webview-ui/src/components/history/__tests__/TaskTitleEditor.spec.tsx b/webview-ui/src/components/history/__tests__/TaskTitleEditor.spec.tsx index 951dc85ac8..46708b84c2 100644 --- a/webview-ui/src/components/history/__tests__/TaskTitleEditor.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskTitleEditor.spec.tsx @@ -12,9 +12,9 @@ vi.mock("@src/i18n/TranslationContext", () => ({ }), })) -const renderEditor = (title?: string) => { +const renderEditor = (title?: string, autoTitle?: string) => { const onDone = vi.fn() - render() + render() return { input: screen.getByTestId("task-title-input"), onDone } } @@ -139,4 +139,34 @@ describe("TaskTitleEditor", () => { expect(vscode.postMessage).not.toHaveBeenCalled() expect(onDone).toHaveBeenCalled() }) + + it.each([ + ["the generated title when there is no custom title", undefined, "Generated name", "Generated name"], + ["the custom title over the generated title", "Custom", "Generated name", "Custom"], + ])("starts with %s", (_label, title, autoTitle, expected) => { + const { input } = renderEditor(title, autoTitle) + + expect(input).toHaveValue(expected) + }) + + it("does nothing when saving the generated title unchanged", () => { + const { input, onDone } = renderEditor(undefined, "Generated name") + + submit(input, " Generated name ") + + expect(vscode.postMessage).not.toHaveBeenCalled() + expect(onDone).toHaveBeenCalled() + }) + + it("saves an edited generated title as a custom title", () => { + const { input } = renderEditor(undefined, "Generated name") + + submit(input, "Edited name") + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "renameTaskWithId", + taskId: "task-1", + text: "Edited name", + }) + }) }) diff --git a/webview-ui/src/components/history/__tests__/types.spec.ts b/webview-ui/src/components/history/__tests__/types.spec.ts new file mode 100644 index 0000000000..06f78bfd3a --- /dev/null +++ b/webview-ui/src/components/history/__tests__/types.spec.ts @@ -0,0 +1,12 @@ +import { getTaskDisplayTitle } from "../types" + +describe("getTaskDisplayTitle", () => { + it.each([ + ["the custom title first", { task: "Prompt", title: "Custom", autoTitle: "Generated" }, "Custom"], + ["the generated title without a custom title", { task: "Prompt", autoTitle: "Generated" }, "Generated"], + ["the prompt when neither title is set", { task: "Prompt" }, "Prompt"], + ["the prompt when titles are empty", { task: "Prompt", title: "", autoTitle: "" }, "Prompt"], + ])("returns %s", (_label, item, expected) => { + expect(getTaskDisplayTitle(item)).toBe(expected) + }) +}) diff --git a/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx b/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx index 179fa9d3de..61531c9e07 100644 --- a/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx +++ b/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx @@ -172,6 +172,57 @@ describe("useTaskSearch", () => { ]) }) + it("searches the generated title and the original prompt of an auto-titled task", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [{ ...mockTaskHistory[1], autoTitle: "Coverage sprint" }], + cwd: "/workspace/project1", + }) + const { result } = renderHook(() => useTaskSearch()) + + act(() => { + result.current.setSearchQuery("Coverage") + }) + expect(result.current.tasks).toEqual([ + expect.objectContaining({ id: "task-2", highlight: "Coverage sprint" }), + ]) + + act(() => { + result.current.setSearchQuery("unit tests") + }) + expect(result.current.tasks).toEqual([expect.objectContaining({ id: "task-2" })]) + }) + + describe("with the real highlighter", () => { + beforeEach(async () => { + const actual = await vi.importActual("@/utils/highlight") + mockHighlightFzfMatch.mockImplementation(actual.highlightFzfMatch) + }) + + afterEach(() => { + mockHighlightFzfMatch.mockImplementation((text: string) => `${text}`) + }) + + it.each([ + { name: "only the original prompt", query: "unit tests" }, + { name: "the title", query: "Docs" }, + ])("escapes an HTML-bearing title when the search matches $name", ({ query }) => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [{ ...mockTaskHistory[1], autoTitle: 'Docs' }], + cwd: "/workspace/project1", + }) + const { result } = renderHook(() => useTaskSearch()) + + act(() => { + result.current.setSearchQuery(query) + }) + + expect(result.current.tasks).toEqual([expect.objectContaining({ id: "task-2" })]) + const highlight = (result.current.tasks[0] as { highlight?: string }).highlight + expect(highlight).toContain("<a href="x">") + expect(highlight).not.toContain(" { // "Write unit tests" (16 chars) vs "Coverage sprint with deep tests" (31 chars): // with the old task.length bound, every position of "deep" (21-24) would be filtered out. diff --git a/webview-ui/src/components/history/types.ts b/webview-ui/src/components/history/types.ts index 8eca94abcb..ec8b47b7fc 100644 --- a/webview-ui/src/components/history/types.ts +++ b/webview-ui/src/components/history/types.ts @@ -11,10 +11,10 @@ export interface DisplayHistoryItem extends HistoryItem { } /** - * The text shown for a task in history: the user-assigned title when set, otherwise the original prompt. + * The text shown for a task in history: the user-assigned title, then the generated title, then the original prompt. */ -export function getTaskDisplayTitle(item: Pick): string { - return item.title || item.task +export function getTaskDisplayTitle(item: Pick): string { + return item.title || item.autoTitle || item.task } /** diff --git a/webview-ui/src/components/history/useTaskSearch.ts b/webview-ui/src/components/history/useTaskSearch.ts index 9ac00a591e..b7a3999fb9 100644 --- a/webview-ui/src/components/history/useTaskSearch.ts +++ b/webview-ui/src/components/history/useTaskSearch.ts @@ -35,7 +35,7 @@ export const useTaskSearch = () => { const fzf = useMemo(() => { return new Fzf(presentableTasks, { - selector: getTaskDisplayTitle, + selector: (item) => `${getTaskDisplayTitle(item)} ${item.task}`, }) }, [presentableTasks]) diff --git a/webview-ui/src/components/settings/PromptsSettings.tsx b/webview-ui/src/components/settings/PromptsSettings.tsx index d6e0ba0f6b..9e7897884a 100644 --- a/webview-ui/src/components/settings/PromptsSettings.tsx +++ b/webview-ui/src/components/settings/PromptsSettings.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, FormEvent } from "react" import { VSCodeTextArea, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" +import { DEFAULT_TASK_TITLE_GENERATION_ENABLED } from "@roo-code/types" import { supportPrompt, SupportPromptType } from "@roo/support-prompt" import { vscode } from "@src/utils/vscode" @@ -25,6 +26,10 @@ interface PromptsSettingsProps { setCustomSupportPrompts: (prompts: Record) => void includeTaskHistoryInEnhance?: boolean setIncludeTaskHistoryInEnhance?: (value: boolean) => void + taskTitleGenerationEnabled?: boolean + setTaskTitleGenerationEnabled?: (value: boolean) => void + taskTitleApiConfigId?: string + setTaskTitleApiConfigId?: (value: string) => void } const PromptsSettings = ({ @@ -32,6 +37,10 @@ const PromptsSettings = ({ setCustomSupportPrompts, includeTaskHistoryInEnhance: propsIncludeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance: propsSetIncludeTaskHistoryInEnhance, + taskTitleGenerationEnabled = DEFAULT_TASK_TITLE_GENERATION_ENABLED, + setTaskTitleGenerationEnabled, + taskTitleApiConfigId, + setTaskTitleApiConfigId, }: PromptsSettingsProps) => { const { t } = useAppTranslation() const { @@ -250,6 +259,58 @@ const PromptsSettings = ({ )} + + {activeSupportOption === "TASK_TITLE" && ( +
+
+ ) => { + const target = ("target" in e ? e.target : null) as HTMLInputElement | null + if (target) { + setTaskTitleGenerationEnabled?.(target.checked) + } + }}> + {t("prompts:supportPrompts.taskTitle.enabled")} + +
+ {t("prompts:supportPrompts.taskTitle.enabledDescription")} +
+
+ +
+ + +
+ {t("prompts:supportPrompts.taskTitle.apiConfigDescription")} +
+
+
+ )} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 26955663ee..c7cb8f9f09 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -39,6 +39,7 @@ import { DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + DEFAULT_TASK_TITLE_GENERATION_ENABLED, ImageGenerationProvider, } from "@roo-code/types" @@ -223,6 +224,8 @@ const SettingsView = forwardRef(({ onDone, t includeDiagnosticMessages, maxDiagnosticMessages, includeTaskHistoryInEnhance, + taskTitleGenerationEnabled, + taskTitleApiConfigId, imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, @@ -471,6 +474,8 @@ const SettingsView = forwardRef(({ onDone, t alwaysAllowFollowupQuestions: alwaysAllowFollowupQuestions ?? false, followupAutoApproveTimeoutMs, includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true, + taskTitleGenerationEnabled: taskTitleGenerationEnabled ?? DEFAULT_TASK_TITLE_GENERATION_ENABLED, + taskTitleApiConfigId: taskTitleApiConfigId ?? "", reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, chatFontSize: chatFontSize ?? null, timeFormat: timeFormat ?? "auto", @@ -994,6 +999,14 @@ const SettingsView = forwardRef(({ onDone, t setIncludeTaskHistoryInEnhance={(value) => setCachedStateField("includeTaskHistoryInEnhance", value) } + taskTitleGenerationEnabled={ + taskTitleGenerationEnabled ?? DEFAULT_TASK_TITLE_GENERATION_ENABLED + } + setTaskTitleGenerationEnabled={(value) => + setCachedStateField("taskTitleGenerationEnabled", value) + } + taskTitleApiConfigId={taskTitleApiConfigId} + setTaskTitleApiConfigId={(value) => setCachedStateField("taskTitleApiConfigId", value)} /> )} diff --git a/webview-ui/src/components/settings/__tests__/PromptsSettings.taskTitle.spec.tsx b/webview-ui/src/components/settings/__tests__/PromptsSettings.taskTitle.spec.tsx new file mode 100644 index 0000000000..17ad3f0644 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/PromptsSettings.taskTitle.spec.tsx @@ -0,0 +1,119 @@ +// npx vitest src/components/settings/__tests__/PromptsSettings.taskTitle.spec.tsx + +import React from "react" + +import { render, screen, fireEvent } from "@/utils/test-utils" + +import PromptsSettings from "../PromptsSettings" +import { vscode } from "@src/utils/vscode" + +vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } })) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + listApiConfigMeta: [ + { id: "cheap", name: "Cheap" }, + { id: "big", name: "Big" }, + ], + enhancementApiConfigId: "", + setEnhancementApiConfigId: vi.fn(), + includeTaskHistoryInEnhance: true, + setIncludeTaskHistoryInEnhance: vi.fn(), + }), +})) + +// Radix Select needs pointer APIs jsdom lacks; a native select keeps the value/change contract. +vi.mock("@src/components/ui", async (importOriginal) => { + const actual = await importOriginal>() + const Select = ({ + value, + onValueChange, + children, + }: { + value: string + onValueChange: (value: string) => void + children: React.ReactNode + }) => ( + + ) + const Passthrough = ({ children }: { children?: React.ReactNode }) => <>{children} + return { + ...actual, + Select, + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: Passthrough, + SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => ( + + ), + } +}) + +const renderTaskTitleTab = (overrides: Record = {}) => { + const setTaskTitleGenerationEnabled = vi.fn() + const setTaskTitleApiConfigId = vi.fn() + render( + , + ) + fireEvent.change(screen.getAllByRole("combobox")[0], { target: { value: "TASK_TITLE" } }) + return { setTaskTitleGenerationEnabled, setTaskTitleApiConfigId } +} + +const getCheckbox = () => + screen + .getByText("prompts:supportPrompts.taskTitle.enabled") + .closest("label")! + .querySelector('input[type="checkbox"]') as HTMLInputElement + +describe("PromptsSettings - TASK_TITLE tab", () => { + beforeEach(() => vi.clearAllMocks()) + + it("is hidden on other support prompt tabs", () => { + render() + + expect(screen.queryByText("prompts:supportPrompts.taskTitle.enabled")).not.toBeInTheDocument() + }) + + it.each([ + ["defaults to enabled when unset", undefined, true], + ["reflects enabled", true, true], + ["reflects disabled", false, false], + ])("%s", (_label, value, checked) => { + renderTaskTitleTab({ taskTitleGenerationEnabled: value }) + + expect(getCheckbox().checked).toBe(checked) + }) + + it("buffers the toggle through the setter without persisting before Save", () => { + const { setTaskTitleGenerationEnabled } = renderTaskTitleTab({ taskTitleGenerationEnabled: true }) + + fireEvent.click(getCheckbox()) + + expect(setTaskTitleGenerationEnabled).toHaveBeenCalledWith(false) + expect(vscode.postMessage).not.toHaveBeenCalled() + }) + + it.each([ + ["selecting a profile", "cheap", "cheap"], + ["choosing the task profile", "-", ""], + ])("buffers %s through the setter without persisting before Save", (_label, selected, expected) => { + const { setTaskTitleApiConfigId } = renderTaskTitleTab({ taskTitleApiConfigId: "big" }) + + fireEvent.change(screen.getAllByRole("combobox")[1], { target: { value: selected } }) + + expect(setTaskTitleApiConfigId).toHaveBeenCalledWith(expected) + expect(vscode.postMessage).not.toHaveBeenCalled() + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/PromptsSettings.visual.fixture.tsx b/webview-ui/src/components/settings/__tests__/PromptsSettings.visual.fixture.tsx new file mode 100644 index 0000000000..4fb552b5c0 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/PromptsSettings.visual.fixture.tsx @@ -0,0 +1,35 @@ +import { useState } from "react" + +import { providerIdentifiers } from "@roo-code/types" + +import PromptsSettings from "../PromptsSettings" +import { AppProviders } from "../../../../playwright/AppProviders" + +export function PromptsSettingsStory() { + const [customSupportPrompts, setCustomSupportPrompts] = useState>({}) + const [enabled, setEnabled] = useState(true) + const [apiConfigId, setApiConfigId] = useState("") + + return ( + +
+ +
+
+ ) +} diff --git a/webview-ui/src/components/settings/__tests__/PromptsSettings.visual.tsx b/webview-ui/src/components/settings/__tests__/PromptsSettings.visual.tsx new file mode 100644 index 0000000000..3279c8e73a --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/PromptsSettings.visual.tsx @@ -0,0 +1,28 @@ +import { expect, test } from "../../../../playwright/coverage-fixture" +import { expectContrast } from "../../../../playwright/contrast" +import { expectBoundedLayout } from "../../../../playwright/layout-contracts" +import { mountedStory } from "../../../../playwright/mounted-story" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" + +for (const theme of visualThemes) { + test(`renders the task title prompt settings in the VS Code ${theme.name} theme`, async ({ mount, page }) => { + const component = mountedStory(await mount("prompts-settings")) + await applyVisualTheme(page, theme) + // The option list is taller than the default viewport, which would leave the last option unclickable. + await page.setViewportSize({ width: 800, height: 1400 }) + const story = component.getByTestId("prompts-settings-story") + await story.getByTestId("support-prompt-select-trigger").click() + await page.getByTestId("TASK_TITLE-option").click() + + const toggle = story.getByTestId("task-title-enabled-checkbox") + await expect(toggle).toBeVisible() + const profileSelect = story.getByTestId("task-title-api-config-select") + await expect(profileSelect).toBeVisible() + await expectContrast(profileSelect, { + background: profileSelect.locator(".."), + label: `${theme.name} task title profile selector`, + }) + await expect(story).toHaveScreenshot(`prompts-settings-task-title-${theme.name}.png`) + await expectBoundedLayout(page, story, { focusedControl: profileSelect }) + }) +} diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.taskTitleSave.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.taskTitleSave.spec.tsx new file mode 100644 index 0000000000..20e522a964 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/SettingsView.taskTitleSave.spec.tsx @@ -0,0 +1,99 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react" +import { QueryClient } from "@tanstack/react-query" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import type { ExtensionState } from "@roo-code/types" + +import { renderWithExtensionState } from "@/utils/test-utils" +import { vscode } from "@src/utils/vscode" + +import SettingsView from "../SettingsView" + +vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } })) + +vi.mock("@/utils/TelemetryClient", () => ({ telemetryClient: { capture: vi.fn() } })) + +// The tab's own controls are covered by PromptsSettings.taskTitle.spec; this stub drives the setters +// SettingsView passes down so the test can follow the cachedState-to-Save path. +vi.mock("../PromptsSettings", () => ({ + default: (props: { + taskTitleGenerationEnabled?: boolean + setTaskTitleGenerationEnabled?: (value: boolean) => void + taskTitleApiConfigId?: string + setTaskTitleApiConfigId?: (value: string) => void + }) => ( +
+ {String(props.taskTitleGenerationEnabled)} + {props.taskTitleApiConfigId ?? ""} +
+ ), +})) + +const postMessage = vi.mocked(vscode.postMessage) + +type SavedSettings = Partial> + +const savedSettings = (): SavedSettings[] => + postMessage.mock.calls + .map((call) => call[0] as { type: string; updatedSettings?: SavedSettings }) + .filter((message) => message.type === "updateSettings") + .map((message) => message.updatedSettings ?? {}) + +const renderPrompts = (state: Partial) => + renderWithExtensionState(, { + state, + queryClient: new QueryClient({ defaultOptions: { queries: { retry: false } } }), + }) + +const save = async () => { + await waitFor(() => expect(screen.getByTestId("save-button")).toBeEnabled()) + fireEvent.click(screen.getByTestId("save-button")) +} + +describe("SettingsView – saving the task title settings", () => { + beforeEach(() => postMessage.mockClear()) + + it("buffers both edits and sends them only when Save is clicked", async () => { + renderPrompts({ taskTitleGenerationEnabled: true }) + + fireEvent.click(await screen.findByTestId("stub-toggle")) + fireEvent.click(screen.getByTestId("stub-pick-light")) + + expect(screen.getByTestId("stub-enabled")).toHaveTextContent("false") + expect(screen.getByTestId("stub-profile")).toHaveTextContent("light") + expect(savedSettings()).toEqual([]) + + await save() + + expect(savedSettings()).toHaveLength(1) + expect(savedSettings()[0]).toMatchObject({ taskTitleGenerationEnabled: false, taskTitleApiConfigId: "light" }) + }) + + it("sends an empty profile id when the selected profile is cleared", async () => { + renderPrompts({ taskTitleApiConfigId: "light" }) + + fireEvent.click(await screen.findByTestId("stub-clear-profile")) + await save() + + expect(savedSettings()[0]).toMatchObject({ taskTitleApiConfigId: "" }) + }) + + it.each([ + { stored: undefined, expected: true }, + { stored: false, expected: false }, + ])("saves the enabled flag as $expected when the stored value is $stored", async ({ stored, expected }) => { + renderPrompts({ taskTitleGenerationEnabled: stored }) + + // Edit an unrelated title setting so Save becomes available without touching the flag. + fireEvent.click(await screen.findByTestId("stub-pick-light")) + await save() + + expect(savedSettings()[0]).toMatchObject({ taskTitleGenerationEnabled: expected }) + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/prompts-settings-task-title-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/prompts-settings-task-title-dark.png new file mode 100644 index 0000000000..733a541654 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/prompts-settings-task-title-dark.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/prompts-settings-task-title-high-contrast-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/prompts-settings-task-title-high-contrast-light.png new file mode 100644 index 0000000000..3754a5734f Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/prompts-settings-task-title-high-contrast-light.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/prompts-settings-task-title-high-contrast.png b/webview-ui/src/components/settings/__tests__/__screenshots__/prompts-settings-task-title-high-contrast.png new file mode 100644 index 0000000000..ec2d9de023 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/prompts-settings-task-title-high-contrast.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/prompts-settings-task-title-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/prompts-settings-task-title-light.png new file mode 100644 index 0000000000..d5a4070708 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/prompts-settings-task-title-light.png differ diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 770f051dc5..38bec0fd8b 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -614,7 +614,10 @@ export const ExtensionStateContextProvider: React.FC<{ ...prevState, taskHistory: nextHistory, currentTaskItem: - prevState.currentTaskItem?.id === item.id ? item : prevState.currentTaskItem, + prevState.currentTaskItem?.id === item.id || + (!prevState.currentTaskItem && prevState.currentTaskId === item.id) + ? item + : prevState.currentTaskItem, } }) break diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 424255245a..7158097960 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -142,6 +142,39 @@ const InitialStateTestComponent = () => { describe("ExtensionStateContext", () => { afterEach(() => vi.restoreAllMocks()) + it.each([ + ["the item matches the pinned task id and no item is loaded yet", "task-a", undefined, "task-a", true], + ["the item belongs to another task", "task-a", undefined, "task-b", false], + ["a different current item is already loaded", "task-a", "task-b", "task-a", false], + ])( + "adopts a pushed history item as currentTaskItem when %s", + (_label, currentTaskId, loadedId, itemId, adopted) => { + const base = { number: 1, ts: 1, task: "t", tokensIn: 0, tokensOut: 0, totalCost: 0 } + const loaded = loadedId ? { ...base, id: loadedId } : undefined + const item = { ...base, id: itemId, autoTitle: "Generated" } + render( + + + , + ) + + act(() => + window.dispatchEvent( + new MessageEvent("message", { data: { type: "taskHistoryItemUpdated", taskHistoryItem: item } }), + ), + ) + + const text = screen.getByTestId("current-task-item").textContent + const current = text ? JSON.parse(text) : null + if (adopted) { + expect(current).toEqual(item) + } else { + expect(current).toEqual(loaded ?? null) + } + }, + ) + it("interns initial history and current task items and subsequent state pushes", () => { const internSpy = vi.spyOn(StringCache.prototype, "intern") const item = { id: "task-a", number: 1, ts: 1, task: "history", tokensIn: 0, tokensOut: 0, totalCost: 0 } diff --git a/webview-ui/src/i18n/locales/ca/prompts.json b/webview-ui/src/i18n/locales/ca/prompts.json index 7e1db1e819..c2ae9824dd 100644 --- a/webview-ui/src/i18n/locales/ca/prompts.json +++ b/webview-ui/src/i18n/locales/ca/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "Inclou l'historial de tasques com a context", "includeTaskHistoryDescription": "Quan està activat, els últims 10 missatges de la conversa actual s'inclouran com a context en millorar els prompts, ajudant a generar suggeriments més rellevants i conscients del context." }, + "taskTitle": { + "enabled": "Genera automàticament els títols de les tasques", + "enabledDescription": "Resumeix en segon pla el primer missatge de cada tasca nova en un títol curt. Les sol·licituds de títol no s'inclouen al cost de la tasca. Els perfils de GitHub Copilot i VS Code LM consumeixen la teva quota.", + "apiConfiguration": "Configuració de l'API", + "useTaskConfig": "Utilitza la configuració de l'API de la tasca", + "apiConfigDescription": "Es recomana un perfil lleuger sense raonament. Si tries un altre perfil, l'inici del teu missatge s'enviarà a aquest proveïdor." + }, "types": { "ENHANCE": { "label": "Millorar prompt", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "Iniciar nova tasca", "description": "Inicieu una nova tasca amb l'entrada proporcionada. Disponible a la paleta de comandes." + }, + "TASK_TITLE": { + "label": "Títol de la tasca", + "description": "Genera en segon pla un títol curt per a les tasques noves. Es mostra a l'historial i a la capçalera de la tasca. Configura-ho a sota." } } }, diff --git a/webview-ui/src/i18n/locales/de/prompts.json b/webview-ui/src/i18n/locales/de/prompts.json index 5d376bedc8..c67b0670fa 100644 --- a/webview-ui/src/i18n/locales/de/prompts.json +++ b/webview-ui/src/i18n/locales/de/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "Aufgabenverlauf als Kontext einbeziehen", "includeTaskHistoryDescription": "Wenn aktiviert, werden die letzten 10 Nachrichten aus der aktuellen Unterhaltung als Kontext beim Verbessern von Prompts einbezogen, um relevantere und kontextbewusste Vorschläge zu generieren." }, + "taskTitle": { + "enabled": "Aufgabentitel automatisch erzeugen", + "enabledDescription": "Fasst den ersten Prompt jeder neuen Aufgabe im Hintergrund zu einem kurzen Titel zusammen. Titelanfragen sind nicht in den Aufgabenkosten enthalten. Profile für GitHub Copilot und VS Code LM verbrauchen dein Kontingent.", + "apiConfiguration": "API-Konfiguration", + "useTaskConfig": "API-Konfiguration der Aufgabe verwenden", + "apiConfigDescription": "Ein leichtgewichtiges Profil ohne Reasoning wird empfohlen. Wenn du ein anderes Profil wählst, wird der Anfang deines Prompts an diesen Anbieter gesendet." + }, "types": { "ENHANCE": { "label": "Prompt verbessern", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "Neue Aufgabe starten", "description": "Starte eine neue Aufgabe mit deiner Eingabe. Verfügbar in der Befehlspalette." + }, + "TASK_TITLE": { + "label": "Aufgabentitel", + "description": "Erzeugt im Hintergrund einen kurzen Titel für neue Aufgaben. Er wird im Verlauf und in der Aufgabenkopfzeile angezeigt. Unten konfigurieren." } } }, diff --git a/webview-ui/src/i18n/locales/en/prompts.json b/webview-ui/src/i18n/locales/en/prompts.json index 31d3ae82b8..206ea3dda2 100644 --- a/webview-ui/src/i18n/locales/en/prompts.json +++ b/webview-ui/src/i18n/locales/en/prompts.json @@ -88,6 +88,13 @@ "includeTaskHistory": "Include task history as context", "includeTaskHistoryDescription": "When enabled, the last 10 messages from the current conversation will be included as context when enhancing prompts, helping to generate more relevant and context-aware suggestions." }, + "taskTitle": { + "enabled": "Generate task titles automatically", + "enabledDescription": "Summarizes the first prompt of each new task into a short title in the background. Title requests are not included in the task cost. GitHub Copilot and VS Code LM profiles consume your quota.", + "apiConfiguration": "API Configuration", + "useTaskConfig": "Same as the task", + "apiConfigDescription": "A lightweight, non-reasoning profile is recommended. If you select a different profile, the beginning of your prompt is sent to that provider." + }, "types": { "ENHANCE": { "label": "Enhance Prompt", @@ -128,6 +135,10 @@ "NEW_TASK": { "label": "Start New Task", "description": "Start a new task with user input. Available in the Command Palette." + }, + "TASK_TITLE": { + "label": "Task Title", + "description": "Generate a short title for new tasks in the background. Shown in history and the task header. Configure it below." } } }, diff --git a/webview-ui/src/i18n/locales/es/prompts.json b/webview-ui/src/i18n/locales/es/prompts.json index a11975e0ab..971c4f9af5 100644 --- a/webview-ui/src/i18n/locales/es/prompts.json +++ b/webview-ui/src/i18n/locales/es/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "Incluir historial de tareas como contexto", "includeTaskHistoryDescription": "Cuando está habilitado, los últimos 10 mensajes de la conversación actual se incluirán como contexto al mejorar solicitudes, ayudando a generar sugerencias más relevantes y conscientes del contexto." }, + "taskTitle": { + "enabled": "Generar títulos de tareas automáticamente", + "enabledDescription": "Resume en segundo plano el primer mensaje de cada tarea nueva en un título corto. Las solicitudes de título no se incluyen en el coste de la tarea. Los perfiles de GitHub Copilot y VS Code LM consumen tu cuota.", + "apiConfiguration": "Configuración de API", + "useTaskConfig": "Usar la configuración de API de la tarea", + "apiConfigDescription": "Se recomienda un perfil ligero sin razonamiento. Si eliges otro perfil, el comienzo de tu mensaje se enviará a ese proveedor." + }, "types": { "ENHANCE": { "label": "Mejorar solicitud", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "Iniciar nueva tarea", "description": "Inicia una nueva tarea con entrada del usuario. Disponible en la Paleta de comandos." + }, + "TASK_TITLE": { + "label": "Título de la tarea", + "description": "Genera en segundo plano un título corto para las tareas nuevas. Se muestra en el historial y en el encabezado de la tarea. Configúralo abajo." } } }, diff --git a/webview-ui/src/i18n/locales/fr/prompts.json b/webview-ui/src/i18n/locales/fr/prompts.json index c1e613710c..30ed6aaaa3 100644 --- a/webview-ui/src/i18n/locales/fr/prompts.json +++ b/webview-ui/src/i18n/locales/fr/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "Inclure l'historique des tâches comme contexte", "includeTaskHistoryDescription": "Lorsque activé, les 10 derniers messages de la conversation actuelle seront inclus comme contexte lors de l'amélioration des prompts, aidant à générer des suggestions plus pertinentes et conscientes du contexte." }, + "taskTitle": { + "enabled": "Générer automatiquement les titres de tâche", + "enabledDescription": "Résume en arrière-plan le premier prompt de chaque nouvelle tâche en un titre court. Les requêtes de titre ne sont pas incluses dans le coût de la tâche. Les profils GitHub Copilot et VS Code LM consomment votre quota.", + "apiConfiguration": "Configuration d'API", + "useTaskConfig": "Utiliser la configuration d'API de la tâche", + "apiConfigDescription": "Un profil léger sans raisonnement est recommandé. Si vous choisissez un autre profil, le début de votre prompt est envoyé à ce fournisseur." + }, "types": { "ENHANCE": { "label": "Améliorer le prompt", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "Démarrer une nouvelle tâche", "description": "Démarre une nouvelle tâche avec ton entrée. Disponible dans la palette de commandes." + }, + "TASK_TITLE": { + "label": "Titre de la tâche", + "description": "Génère en arrière-plan un titre court pour les nouvelles tâches. Il apparaît dans l'historique et l'en-tête de la tâche. Configurez-le ci-dessous." } } }, diff --git a/webview-ui/src/i18n/locales/hi/prompts.json b/webview-ui/src/i18n/locales/hi/prompts.json index a610828619..1a76114d94 100644 --- a/webview-ui/src/i18n/locales/hi/prompts.json +++ b/webview-ui/src/i18n/locales/hi/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "कार्य इतिहास को संदर्भ के रूप में शामिल करें", "includeTaskHistoryDescription": "जब सक्षम किया जाता है, तो वर्तमान बातचीत के अंतिम 10 संदेश प्रॉम्प्ट को बेहतर बनाते समय संदर्भ के रूप में शामिल किए जाएंगे, जो अधिक प्रासंगिक और संदर्भ-जागरूक सुझाव उत्पन्न करने में मदद करेगा।" }, + "taskTitle": { + "enabled": "कार्य शीर्षक अपने-आप बनाएँ", + "enabledDescription": "हर नए कार्य के पहले प्रॉम्प्ट को बैकग्राउंड में एक छोटे शीर्षक में सारांशित करता है। शीर्षक अनुरोध कार्य की लागत में शामिल नहीं होते। GitHub Copilot और VS Code LM प्रोफ़ाइल आपका कोटा खर्च करती हैं।", + "apiConfiguration": "API कॉन्फ़िगरेशन", + "useTaskConfig": "कार्य का API कॉन्फ़िगरेशन उपयोग करें", + "apiConfigDescription": "हल्की, बिना रीज़निंग वाली प्रोफ़ाइल अनुशंसित है। यदि आप दूसरी प्रोफ़ाइल चुनते हैं, तो आपके प्रॉम्प्ट का शुरुआती हिस्सा उसी प्रदाता को भेजा जाएगा।" + }, "types": { "ENHANCE": { "label": "प्रॉम्प्ट बढ़ाएँ", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "नया कार्य शुरू करें", "description": "इनपुट के साथ नया कार्य शुरू करें। कमांड पैलेट में उपलब्ध है।" + }, + "TASK_TITLE": { + "label": "कार्य शीर्षक", + "description": "नए कार्यों के लिए बैकग्राउंड में एक छोटा शीर्षक बनाता है। यह इतिहास और कार्य हेडर में दिखता है। नीचे कॉन्फ़िगर करें।" } } }, diff --git a/webview-ui/src/i18n/locales/id/prompts.json b/webview-ui/src/i18n/locales/id/prompts.json index a5bb8d8405..6999fbcfe5 100644 --- a/webview-ui/src/i18n/locales/id/prompts.json +++ b/webview-ui/src/i18n/locales/id/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "Sertakan riwayat tugas sebagai konteks", "includeTaskHistoryDescription": "Ketika diaktifkan, 10 pesan terakhir dari percakapan saat ini akan disertakan sebagai konteks saat meningkatkan prompt, membantu menghasilkan saran yang lebih relevan dan sadar konteks." }, + "taskTitle": { + "enabled": "Buat judul tugas secara otomatis", + "enabledDescription": "Meringkas prompt pertama setiap tugas baru menjadi judul singkat di latar belakang. Permintaan judul tidak termasuk dalam biaya tugas. Profil GitHub Copilot dan VS Code LM menggunakan kuota Anda.", + "apiConfiguration": "Konfigurasi API", + "useTaskConfig": "Gunakan konfigurasi API tugas", + "apiConfigDescription": "Disarankan profil ringan tanpa penalaran. Jika Anda memilih profil lain, bagian awal prompt Anda dikirim ke penyedia tersebut." + }, "types": { "ENHANCE": { "label": "Tingkatkan Prompt", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "Mulai Tugas Baru", "description": "Mulai tugas baru dengan input pengguna. Tersedia di Command Palette." + }, + "TASK_TITLE": { + "label": "Judul Tugas", + "description": "Membuat judul singkat untuk tugas baru di latar belakang. Ditampilkan di riwayat dan header tugas. Atur di bawah." } } }, diff --git a/webview-ui/src/i18n/locales/it/prompts.json b/webview-ui/src/i18n/locales/it/prompts.json index a7e7816b80..f699f27fef 100644 --- a/webview-ui/src/i18n/locales/it/prompts.json +++ b/webview-ui/src/i18n/locales/it/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "Includi cronologia attività come contesto", "includeTaskHistoryDescription": "Quando abilitato, gli ultimi 10 messaggi della conversazione corrente verranno inclusi come contesto durante il miglioramento dei prompt, aiutando a generare suggerimenti più rilevanti e consapevoli del contesto." }, + "taskTitle": { + "enabled": "Genera automaticamente i titoli delle attività", + "enabledDescription": "Riassume in background il primo prompt di ogni nuova attività in un titolo breve. Le richieste di titolo non sono incluse nel costo dell'attività. I profili GitHub Copilot e VS Code LM consumano la tua quota.", + "apiConfiguration": "Configurazione API", + "useTaskConfig": "Usa la configurazione API dell'attività", + "apiConfigDescription": "Si consiglia un profilo leggero senza ragionamento. Se scegli un altro profilo, l'inizio del prompt viene inviato a quel provider." + }, "types": { "ENHANCE": { "label": "Migliora prompt", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "Avvia nuova attività", "description": "Avvia una nuova attività con il tuo input. Disponibile nella palette dei comandi." + }, + "TASK_TITLE": { + "label": "Titolo dell'attività", + "description": "Genera in background un titolo breve per le nuove attività. Viene mostrato nella cronologia e nell'intestazione dell'attività. Configuralo qui sotto." } } }, diff --git a/webview-ui/src/i18n/locales/ja/prompts.json b/webview-ui/src/i18n/locales/ja/prompts.json index 2ac7743c7e..22750ce89e 100644 --- a/webview-ui/src/i18n/locales/ja/prompts.json +++ b/webview-ui/src/i18n/locales/ja/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "タスク履歴をコンテキストとして含める", "includeTaskHistoryDescription": "有効にすると、現在の会話の最後の10メッセージがプロンプト強化時にコンテキストとして含まれ、より関連性が高くコンテキストを意識した提案の生成に役立ちます。" }, + "taskTitle": { + "enabled": "タスクタイトルを自動生成", + "enabledDescription": "新しいタスクの最初のプロンプトを、バックグラウンドで短いタイトルに要約します。タイトル生成のリクエストはタスクのコストに含まれません。GitHub Copilot と VS Code LM のプロファイルはクォータを消費します。", + "apiConfiguration": "API設定", + "useTaskConfig": "タスクで使用しているAPI設定を使用", + "apiConfigDescription": "軽量な非推論プロファイルを推奨します。別のプロファイルを選択すると、プロンプトの冒頭部分がそのプロバイダーに送信されます。" + }, "types": { "ENHANCE": { "label": "プロンプトを強化", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "新しいタスクを開始", "description": "入力内容で新しいタスクを開始できます。コマンドパレットから利用できます。" + }, + "TASK_TITLE": { + "label": "タスクタイトル", + "description": "新しいタスクの短いタイトルをバックグラウンドで生成します。履歴とタスクヘッダーに表示されます。以下で設定できます。" } } }, diff --git a/webview-ui/src/i18n/locales/ko/prompts.json b/webview-ui/src/i18n/locales/ko/prompts.json index bf626fe170..41eaf1e4fa 100644 --- a/webview-ui/src/i18n/locales/ko/prompts.json +++ b/webview-ui/src/i18n/locales/ko/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "작업 기록을 컨텍스트로 포함", "includeTaskHistoryDescription": "활성화하면 현재 대화의 마지막 10개 메시지가 프롬프트 향상 시 컨텍스트로 포함되어 더 관련성 높고 컨텍스트를 인식하는 제안을 생성하는 데 도움이 됩니다." }, + "taskTitle": { + "enabled": "작업 제목 자동 생성", + "enabledDescription": "새 작업의 첫 프롬프트를 백그라운드에서 짧은 제목으로 요약합니다. 제목 생성 요청은 작업 비용에 포함되지 않습니다. GitHub Copilot과 VS Code LM 프로필은 할당량을 소모합니다.", + "apiConfiguration": "API 구성", + "useTaskConfig": "작업에서 사용하는 API 구성 사용", + "apiConfigDescription": "가벼운 비추론(non-reasoning) 프로필을 권장합니다. 다른 프로필을 선택하면 프롬프트의 앞부분이 해당 제공자로 전송됩니다." + }, "types": { "ENHANCE": { "label": "프롬프트 향상", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "새 작업 시작", "description": "입력한 내용으로 새 작업을 시작할 수 있습니다. 명령 팔레트에서 이용 가능합니다." + }, + "TASK_TITLE": { + "label": "작업 제목", + "description": "새 작업의 짧은 제목을 백그라운드에서 생성합니다. 기록과 작업 헤더에 표시됩니다. 아래에서 설정하세요." } } }, diff --git a/webview-ui/src/i18n/locales/nl/prompts.json b/webview-ui/src/i18n/locales/nl/prompts.json index bd316a9ded..6be3c1d761 100644 --- a/webview-ui/src/i18n/locales/nl/prompts.json +++ b/webview-ui/src/i18n/locales/nl/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "Taakgeschiedenis als context opnemen", "includeTaskHistoryDescription": "Wanneer ingeschakeld, worden de laatste 10 berichten van het huidige gesprek opgenomen als context bij het verbeteren van prompts, wat helpt bij het genereren van meer relevante en contextbewuste suggesties." }, + "taskTitle": { + "enabled": "Taaktitels automatisch genereren", + "enabledDescription": "Vat op de achtergrond de eerste prompt van elke nieuwe taak samen tot een korte titel. Titelverzoeken zijn niet opgenomen in de taakkosten. GitHub Copilot- en VS Code LM-profielen gebruiken je quotum.", + "apiConfiguration": "API-configuratie", + "useTaskConfig": "API-configuratie van de taak gebruiken", + "apiConfigDescription": "Een lichtgewicht profiel zonder redenering wordt aanbevolen. Als je een ander profiel kiest, wordt het begin van je prompt naar die provider gestuurd." + }, "types": { "ENHANCE": { "label": "Prompt verbeteren", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "Nieuwe taak starten", "description": "Start een nieuwe taak met gebruikersinvoer. Beschikbaar via de Command Palette." + }, + "TASK_TITLE": { + "label": "Taaktitel", + "description": "Genereert op de achtergrond een korte titel voor nieuwe taken. Wordt getoond in de geschiedenis en de taakkop. Stel het hieronder in." } } }, diff --git a/webview-ui/src/i18n/locales/pl/prompts.json b/webview-ui/src/i18n/locales/pl/prompts.json index a1b665d435..d0eb923f3d 100644 --- a/webview-ui/src/i18n/locales/pl/prompts.json +++ b/webview-ui/src/i18n/locales/pl/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "Uwzględnij historię zadań jako kontekst", "includeTaskHistoryDescription": "Gdy włączone, ostatnie 10 wiadomości z bieżącej rozmowy zostanie uwzględnione jako kontekst podczas ulepszania podpowiedzi, pomagając generować bardziej trafne i świadome kontekstu sugestie." }, + "taskTitle": { + "enabled": "Automatycznie generuj tytuły zadań", + "enabledDescription": "W tle streszcza pierwszy monit każdego nowego zadania do krótkiego tytułu. Żądania tytułów nie są wliczane do kosztu zadania. Profile GitHub Copilot i VS Code LM zużywają Twój limit.", + "apiConfiguration": "Konfiguracja API", + "useTaskConfig": "Użyj konfiguracji API zadania", + "apiConfigDescription": "Zalecany jest lekki profil bez rozumowania. Jeśli wybierzesz inny profil, początek monitu zostanie wysłany do tego dostawcy." + }, "types": { "ENHANCE": { "label": "Ulepsz podpowiedź", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "Rozpocznij nowe zadanie", "description": "Rozpocznij nowe zadanie z wprowadzonymi danymi. Dostępne w palecie poleceń." + }, + "TASK_TITLE": { + "label": "Tytuł zadania", + "description": "Generuje w tle krótki tytuł nowych zadań. Jest widoczny w historii i w nagłówku zadania. Skonfiguruj poniżej." } } }, diff --git a/webview-ui/src/i18n/locales/pt-BR/prompts.json b/webview-ui/src/i18n/locales/pt-BR/prompts.json index 1d61323ca7..461927c886 100644 --- a/webview-ui/src/i18n/locales/pt-BR/prompts.json +++ b/webview-ui/src/i18n/locales/pt-BR/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "Incluir histórico de tarefas como contexto", "includeTaskHistoryDescription": "Quando habilitado, as últimas 10 mensagens da conversa atual serão incluídas como contexto ao aprimorar prompts, ajudando a gerar sugestões mais relevantes e conscientes do contexto." }, + "taskTitle": { + "enabled": "Gerar títulos de tarefa automaticamente", + "enabledDescription": "Resume em segundo plano o primeiro prompt de cada nova tarefa em um título curto. As solicitações de título não são incluídas no custo da tarefa. Perfis do GitHub Copilot e do VS Code LM consomem sua cota.", + "apiConfiguration": "Configuração de API", + "useTaskConfig": "Usar a configuração de API da tarefa", + "apiConfigDescription": "Recomenda-se um perfil leve, sem raciocínio. Se você escolher outro perfil, o início do seu prompt será enviado a esse provedor." + }, "types": { "ENHANCE": { "label": "Aprimorar Prompt", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "Iniciar Nova Tarefa", "description": "Inicie uma nova tarefa com a entrada fornecida. Disponível na paleta de comandos." + }, + "TASK_TITLE": { + "label": "Título da tarefa", + "description": "Gera em segundo plano um título curto para novas tarefas. Ele aparece no histórico e no cabeçalho da tarefa. Configure abaixo." } } }, diff --git a/webview-ui/src/i18n/locales/ru/prompts.json b/webview-ui/src/i18n/locales/ru/prompts.json index ba2cc0593e..f651959cf5 100644 --- a/webview-ui/src/i18n/locales/ru/prompts.json +++ b/webview-ui/src/i18n/locales/ru/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "Включить историю задач как контекст", "includeTaskHistoryDescription": "При включении последние 10 сообщений из текущего разговора будут включены как контекст при улучшении промптов, помогая генерировать более релевантные и контекстно-осведомленные предложения." }, + "taskTitle": { + "enabled": "Автоматически создавать заголовки задач", + "enabledDescription": "В фоновом режиме сжимает первый промпт каждой новой задачи в короткий заголовок. Запросы на создание заголовков не включаются в стоимость задачи. Профили GitHub Copilot и VS Code LM расходуют вашу квоту.", + "apiConfiguration": "Конфигурация API", + "useTaskConfig": "Использовать конфигурацию API задачи", + "apiConfigDescription": "Рекомендуется лёгкий профиль без рассуждений. Если выбрать другой профиль, начало вашего промпта будет отправлено этому провайдеру." + }, "types": { "ENHANCE": { "label": "Улучшить промпт", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "Начать новую задачу", "description": "Начать новую задачу с пользовательским вводом. Доступно в палитре команд." + }, + "TASK_TITLE": { + "label": "Заголовок задачи", + "description": "Создаёт в фоновом режиме короткий заголовок для новых задач. Отображается в истории и в шапке задачи. Настройте ниже." } } }, diff --git a/webview-ui/src/i18n/locales/tr/prompts.json b/webview-ui/src/i18n/locales/tr/prompts.json index 77a4d3434d..46cf69883f 100644 --- a/webview-ui/src/i18n/locales/tr/prompts.json +++ b/webview-ui/src/i18n/locales/tr/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "Görev geçmişini bağlam olarak dahil et", "includeTaskHistoryDescription": "Etkinleştirildiğinde, mevcut konuşmanın son 10 mesajı promptları geliştirirken bağlam olarak dahil edilecek ve daha alakalı ve bağlam farkında öneriler üretmeye yardımcı olacaktır." }, + "taskTitle": { + "enabled": "Görev başlıklarını otomatik oluştur", + "enabledDescription": "Her yeni görevin ilk istemini arka planda kısa bir başlık olarak özetler. Başlık istekleri görev maliyetine dahil edilmez. GitHub Copilot ve VS Code LM profilleri kotanızı tüketir.", + "apiConfiguration": "API Yapılandırması", + "useTaskConfig": "Görevin API yapılandırmasını kullan", + "apiConfigDescription": "Hafif, akıl yürütmesiz bir profil önerilir. Farklı bir profil seçerseniz istemin baş kısmı o sağlayıcıya gönderilir." + }, "types": { "ENHANCE": { "label": "Promptu Geliştir", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "Yeni Görev Başlat", "description": "Girdiyle yeni bir görev başlat. Komut paletinde kullanılabilir." + }, + "TASK_TITLE": { + "label": "Görev Başlığı", + "description": "Yeni görevler için arka planda kısa bir başlık oluşturur. Geçmişte ve görev başlığında gösterilir. Aşağıdan yapılandırın." } } }, diff --git a/webview-ui/src/i18n/locales/vi/prompts.json b/webview-ui/src/i18n/locales/vi/prompts.json index 6ddbfb169d..83b8d2c4cc 100644 --- a/webview-ui/src/i18n/locales/vi/prompts.json +++ b/webview-ui/src/i18n/locales/vi/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "Bao gồm lịch sử tác vụ làm ngữ cảnh", "includeTaskHistoryDescription": "Khi được bật, 10 tin nhắn cuối cùng từ cuộc trò chuyện hiện tại sẽ được bao gồm làm ngữ cảnh khi nâng cao lời nhắc, giúp tạo ra các gợi ý phù hợp và nhận thức ngữ cảnh hơn." }, + "taskTitle": { + "enabled": "Tự động tạo tiêu đề tác vụ", + "enabledDescription": "Tóm tắt lời nhắc đầu tiên của mỗi tác vụ mới thành tiêu đề ngắn ở chế độ nền. Yêu cầu tạo tiêu đề không được tính vào chi phí tác vụ. Hồ sơ GitHub Copilot và VS Code LM sẽ tiêu tốn hạn mức của bạn.", + "apiConfiguration": "Cấu hình API", + "useTaskConfig": "Dùng cấu hình API của tác vụ", + "apiConfigDescription": "Nên dùng hồ sơ nhẹ, không suy luận. Nếu chọn hồ sơ khác, phần đầu lời nhắc của bạn sẽ được gửi đến nhà cung cấp đó." + }, "types": { "ENHANCE": { "label": "Nâng cao lời nhắc", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "Bắt đầu tác vụ mới", "description": "Bắt đầu tác vụ mới với nội dung đã nhập. Có sẵn trong bảng lệnh." + }, + "TASK_TITLE": { + "label": "Tiêu đề tác vụ", + "description": "Tạo tiêu đề ngắn cho tác vụ mới ở chế độ nền. Hiển thị trong lịch sử và phần đầu tác vụ. Cấu hình bên dưới." } } }, diff --git a/webview-ui/src/i18n/locales/zh-CN/prompts.json b/webview-ui/src/i18n/locales/zh-CN/prompts.json index 14aa3eb95c..0c110fc475 100644 --- a/webview-ui/src/i18n/locales/zh-CN/prompts.json +++ b/webview-ui/src/i18n/locales/zh-CN/prompts.json @@ -89,6 +89,13 @@ "includeTaskHistory": "包含任务历史作为上下文", "includeTaskHistoryDescription": "启用后,当前对话的最后 10 条消息将作为上下文包含在增强提示词时,有助于生成更相关和上下文感知的建议。" }, + "taskTitle": { + "enabled": "自动生成任务标题", + "enabledDescription": "在后台将每个新任务的第一条提示总结为简短标题。标题请求不计入任务费用。GitHub Copilot 和 VS Code LM 配置会消耗您的配额。", + "apiConfiguration": "API 配置", + "useTaskConfig": "使用任务的 API 配置", + "apiConfigDescription": "建议使用轻量级的非推理配置。如果选择其他配置,提示的开头部分将发送给该提供商。" + }, "types": { "ENHANCE": { "label": "增强提示词", @@ -129,6 +136,10 @@ "NEW_TASK": { "label": "新任务", "description": "控制开始新任务时的用户提示词。可在首页对话框中使用。" + }, + "TASK_TITLE": { + "label": "任务标题", + "description": "在后台为新任务生成简短标题,显示在历史记录和任务标题栏中。可在下方配置。" } } }, diff --git a/webview-ui/src/i18n/locales/zh-TW/prompts.json b/webview-ui/src/i18n/locales/zh-TW/prompts.json index f83207bd45..2b3ea53864 100644 --- a/webview-ui/src/i18n/locales/zh-TW/prompts.json +++ b/webview-ui/src/i18n/locales/zh-TW/prompts.json @@ -88,6 +88,13 @@ "includeTaskHistory": "將任務歷史紀錄納入內容", "includeTaskHistoryDescription": "啟用後,目前對話的最後 10 則訊息將在強化提示時被納入為前後文,有助於產生更相關且具備情境感知能力的建議。" }, + "taskTitle": { + "enabled": "自動產生工作標題", + "enabledDescription": "在背景將每個新工作的第一個提示摘要為簡短標題。標題請求不會計入工作費用。GitHub Copilot 與 VS Code LM 設定檔會消耗您的配額。", + "apiConfiguration": "API 設定", + "useTaskConfig": "使用工作的 API 設定", + "apiConfigDescription": "建議使用輕量的非推理設定檔。若選擇其他設定檔,提示的開頭部分會傳送給該供應商。" + }, "types": { "ENHANCE": { "label": "強化提示詞", @@ -128,6 +135,10 @@ "NEW_TASK": { "label": "開始新工作", "description": "根據使用者輸入開始一個新的任務。可在命令選擇區使用。" + }, + "TASK_TITLE": { + "label": "工作標題", + "description": "在背景為新工作產生簡短標題,顯示於歷史記錄與工作標頭。可於下方設定。" } } }, diff --git a/webview-ui/src/utils/__tests__/highlight.spec.ts b/webview-ui/src/utils/__tests__/highlight.spec.ts new file mode 100644 index 0000000000..b6d1fde0cb --- /dev/null +++ b/webview-ui/src/utils/__tests__/highlight.spec.ts @@ -0,0 +1,18 @@ +import { highlightFzfMatch } from "../highlight" + +describe("highlightFzfMatch", () => { + it.each([ + { name: "no match positions", positions: [], expected: "<b>x</b>" }, + { + name: "match positions", + positions: [3], + expected: '<b>x</b>', + }, + ])("escapes HTML in the text with $name", ({ positions, expected }) => { + expect(highlightFzfMatch("x", positions)).toBe(expected) + }) + + it("wraps matched characters in the highlight class", () => { + expect(highlightFzfMatch("abc", [0, 2], "hl")).toBe('abc') + }) +}) diff --git a/webview-ui/src/utils/highlight.ts b/webview-ui/src/utils/highlight.ts index 21c84f993a..3362c7fa90 100644 --- a/webview-ui/src/utils/highlight.ts +++ b/webview-ui/src/utils/highlight.ts @@ -29,7 +29,8 @@ export function highlightFzfMatch( positions: number[], highlightClassName: string = "history-item-highlight", ) { - if (!positions.length) return text + // The result is rendered as HTML, so unmatched text must be escaped too. + if (!positions.length) return escapeHtml(text) const parts: { text: string; highlight: boolean }[] = [] let lastIndex = 0