| author | |
| committer | |
| log | c7bf3115f2223ede5cb2316613dc7369155dbeb1 |
| tree | 70e8d6aba3749eb136a580ec6ac785314a5da11e |
| parent | 7d5dc66c151a9ca7bfef45cffc9d3516842c5566 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>3 files changed, 48 insertions(+), 0 deletions(-)
apps/web/src/components/Sidebar.logic.test.ts+14| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | animatePinnedLayoutChanges, |
| 5 | 5 | archiveSelectedThreadEntries, |
| 6 | 6 | buildBulkTitleRegenerationContextMenuItem, |
| 7 | buildBulkUnpinContextMenuItem, | |
| 7 | 8 | buildMultiSelectThreadContextMenuItems, |
| 8 | 9 | createThreadJumpHintVisibilityController, |
| 9 | 10 | filterSidebarProjectScopeItems, |
| ... | ... | @@ -185,6 +186,19 @@ describe("archiveSelectedThreadEntries", () => { |
| 185 | 186 | }); |
| 186 | 187 | }); |
| 187 | 188 | |
| 189 | describe("buildBulkUnpinContextMenuItem", () => { | |
| 190 | it("counts only the pinned rows of a mixed selection", () => { | |
| 191 | expect(buildBulkUnpinContextMenuItem({ pinnedCount: 2 })).toEqual({ | |
| 192 | id: "unpin", | |
| 193 | label: "Unpin (2)", | |
| 194 | }); | |
| 195 | }); | |
| 196 | ||
| 197 | it("omits the action when nothing selected is pinned", () => { | |
| 198 | expect(buildBulkUnpinContextMenuItem({ pinnedCount: 0 })).toBeNull(); | |
| 199 | }); | |
| 200 | }); | |
| 201 | ||
| 188 | 202 | describe("buildBulkTitleRegenerationContextMenuItem", () => { |
| 189 | 203 | it("counts only threads that can start a new regeneration", () => { |
| 190 | 204 | expect( |
apps/web/src/components/Sidebar.logic.ts+12| ... | ... | @@ -177,6 +177,18 @@ export function buildBulkTitleRegenerationContextMenuItem(input: { |
| 177 | 177 | }; |
| 178 | 178 | } |
| 179 | 179 | |
| 180 | /** | |
| 181 | * Bulk unpin follows the same "count only what the action will touch" rule | |
| 182 | * as title regeneration: on a mixed selection the label counts the pinned | |
| 183 | * rows alone, and the item disappears when nothing selected is pinned. | |
| 184 | */ | |
| 185 | export function buildBulkUnpinContextMenuItem(input: { | |
| 186 | pinnedCount: number; | |
| 187 | }): ContextMenuItem<"unpin"> | null { | |
| 188 | if (input.pinnedCount === 0) return null; | |
| 189 | return { id: "unpin", label: `Unpin (${input.pinnedCount})` }; | |
| 190 | } | |
| 191 | ||
| 180 | 192 | export interface ThreadStatusPill { |
| 181 | 193 | label: |
| 182 | 194 | | "Working" |
apps/web/src/components/Sidebar.tsx+22| ... | ... | @@ -130,6 +130,7 @@ import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; |
| 130 | 130 | import { |
| 131 | 131 | animatePinnedLayoutChanges, |
| 132 | 132 | buildBulkTitleRegenerationContextMenuItem, |
| 133 | buildBulkUnpinContextMenuItem, | |
| 133 | 134 | filterSidebarProjectScopeItems, |
| 134 | 135 | formatWorkingDurationLabel, |
| 135 | 136 | firstValidTimestampMs, |
| ... | ... | @@ -3025,10 +3026,22 @@ export default function Sidebar() { |
| 3025 | 3026 | supportedCount: titleRegenerationThreads.length, |
| 3026 | 3027 | actionableCount: regeneratableTitleThreads.length, |
| 3027 | 3028 | }); |
| 3029 | // Unpin (k) counts only the pinned rows in pin-capable environments — | |
| 3030 | // on a mixed selection the unpinned rows are untouched, and the item | |
| 3031 | // is omitted entirely when nothing selected is pinned. | |
| 3032 | const pinnedSelectedThreads = selectedThreads.filter( | |
| 3033 | (thread) => | |
| 3034 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === | |
| 3035 | true && thread.pinnedAt != null, | |
| 3036 | ); | |
| 3037 | const unpinMenuItem = buildBulkUnpinContextMenuItem({ | |
| 3038 | pinnedCount: pinnedSelectedThreads.length, | |
| 3039 | }); | |
| 3028 | 3040 | const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); |
| 3029 | 3041 | const clicked = await settlePromise(() => |
| 3030 | 3042 | api.contextMenu.show( |
| 3031 | 3043 | [ |
| 3044 | ...(unpinMenuItem ? [unpinMenuItem] : []), | |
| 3032 | 3045 | { id: "settle", label: `Settle (${count})` }, |
| 3033 | 3046 | ...(canSnoozeSelection |
| 3034 | 3047 | ? [ |
| ... | ... | @@ -3110,6 +3123,14 @@ export default function Sidebar() { |
| 3110 | 3123 | } |
| 3111 | 3124 | return; |
| 3112 | 3125 | } |
| 3126 | if (clicked.value === "unpin") { | |
| 3127 | // Each unpin reports its own failure, like the single-row action. | |
| 3128 | for (const thread of pinnedSelectedThreads) { | |
| 3129 | attemptUnpin(scopeThreadRef(thread.environmentId, thread.id)); | |
| 3130 | } | |
| 3131 | clearSelection(); | |
| 3132 | return; | |
| 3133 | } | |
| 3113 | 3134 | if (clicked.value === "regenerate-title") { |
| 3114 | 3135 | for (const thread of regeneratableTitleThreads) { |
| 3115 | 3136 | const result = await updateThreadMetadata({ |
| ... | ... | @@ -3199,6 +3220,7 @@ export default function Sidebar() { |
| 3199 | 3220 | [ |
| 3200 | 3221 | attemptSettle, |
| 3201 | 3222 | attemptSnooze, |
| 3223 | attemptUnpin, | |
| 3202 | 3224 | clearSelection, |
| 3203 | 3225 | confirmThreadDelete, |
| 3204 | 3226 | deleteThread, |