From 654eb2be30c7e6ae73985cb33b06a9d89a8672ad Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 28 Jul 2026 23:41:49 -0700 Subject: [PATCH] feat(web): regenerate selected thread titles --- apps/web/src/components/Sidebar.logic.test.ts | 37 +++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 19 ++++++++ apps/web/src/components/SidebarV2.tsx | 47 +++++++++++++++++-- 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 2d6b4f78481..fa4eb5047af 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { archiveSelectedThreadEntries, + buildBulkTitleRegenerationContextMenuItem, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, @@ -82,6 +83,42 @@ describe("isThreadTitleRegenerationPending", () => { }); }); +describe("buildBulkTitleRegenerationContextMenuItem", () => { + it("counts only threads that can start a new regeneration", () => { + expect( + buildBulkTitleRegenerationContextMenuItem({ + supportedCount: 4, + actionableCount: 3, + }), + ).toEqual({ + id: "regenerate-title", + label: "Regenerate titles (3)", + }); + }); + + it("shows a disabled progress item when every supported thread is pending", () => { + expect( + buildBulkTitleRegenerationContextMenuItem({ + supportedCount: 2, + actionableCount: 0, + }), + ).toEqual({ + id: "regenerate-title", + label: "Regenerating… (2)", + disabled: true, + }); + }); + + it("omits the action when no selected environment supports it", () => { + expect( + buildBulkTitleRegenerationContextMenuItem({ + supportedCount: 0, + actionableCount: 0, + }), + ).toBeNull(); + }); +}); + describe("shouldNavigateAfterProjectRemoval", () => { const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 3866405efd5..40f974a4b5e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -42,6 +42,25 @@ export function isThreadTitleRegenerationPending( ): boolean { return threadTitleRegenerationRemainingMs(thread, nowMs) > 0; } + +export function buildBulkTitleRegenerationContextMenuItem(input: { + supportedCount: number; + actionableCount: number; +}): ContextMenuItem<"regenerate-title"> | null { + if (input.supportedCount === 0) return null; + if (input.actionableCount === 0) { + return { + id: "regenerate-title", + label: `Regenerating… (${input.supportedCount})`, + disabled: true, + }; + } + return { + id: "regenerate-title", + label: `Regenerate titles (${input.actionableCount})`, + }; +} + type SidebarProject = { id: string; title: string; diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index adae81402a9..82f6b8486bd 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -105,6 +105,7 @@ import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat" import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { + buildBulkTitleRegenerationContextMenuItem, formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, @@ -1881,16 +1882,28 @@ export default function SidebarV2() { const count = threadKeys.length; // Snooze (N) is offered when every selected thread can actually take // it — a mixed selection with blocked-on-you work would half-apply. - const selectionNow = new Date().toISOString(); - const snoozableThreads = threadKeys.flatMap((threadKey) => { + const selectionNow = new Date(); + const selectedThreads = threadKeys.flatMap((threadKey) => { const thread = threadByKeyRef.current.get(threadKey); return thread ? [thread] : []; }); - const canSnoozeSelection = snoozableThreads.every( + const canSnoozeSelection = selectedThreads.every( (thread) => serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && - canSnooze(thread, { now: selectionNow }), + canSnooze(thread, { now: selectionNow.toISOString() }), ); + const titleRegenerationThreads = selectedThreads.filter( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadTitleRegeneration === true, + ); + const regeneratableTitleThreads = titleRegenerationThreads.filter( + (thread) => !isThreadTitleRegenerationPending(thread, selectionNow.getTime()), + ); + const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({ + supportedCount: titleRegenerationThreads.length, + actionableCount: regeneratableTitleThreads.length, + }); const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => api.contextMenu.show( @@ -1908,6 +1921,7 @@ export default function SidebarV2() { }, ] : []), + ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []), { id: "mark-unread", label: `Mark unread (${count})` }, { id: "delete", label: `Delete (${count})`, destructive: true }, ], @@ -1923,7 +1937,7 @@ export default function SidebarV2() { // Post-snooze navigation must skip threads snoozing in this same // batch — they are all leaving the card block together. const coSnoozingKeys = new Set(threadKeys); - for (const thread of snoozableThreads) { + for (const thread of selectedThreads) { attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, { coSnoozingKeys, }); @@ -1932,6 +1946,28 @@ export default function SidebarV2() { } return; } + if (clicked.value === "regenerate-title") { + for (const thread of regeneratableTitleThreads) { + const result = await updateThreadMetadata({ + environmentId: thread.environmentId, + input: { threadId: thread.id, regenerateTitle: true }, + }); + if (result._tag === "Success") continue; + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to regenerate thread titles", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + clearSelection(); + return; + } if (clicked.value === "settle") { // Post-settle navigation must skip threads settling in this same // batch — they are all leaving the card block together. Rows that @@ -2003,6 +2039,7 @@ export default function SidebarV2() { markThreadUnread, removeFromSelection, serverConfigs, + updateThreadMetadata, ], );