authorgravatar for gianmarcosimone89@gmail.comGianmarco <gianmarcosimone89@gmail.com> 2026-09-04 19:49:26+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 13:49:26-04:00
logc7bf3115f2223ede5cb2316613dc7369155dbeb1
tree70e8d6aba3749eb136a580ec6ac785314a5da11e
parent7d5dc66c151a9ca7bfef45cffc9d3516842c5566
signature Signed by PGP key B5690EEEBB952194

feat(web): unpin threads from the sidebar multi-select menu (#9651)

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,6 +4,7 @@ import {
4 animatePinnedLayoutChanges,4 animatePinnedLayoutChanges,
5 archiveSelectedThreadEntries,5 archiveSelectedThreadEntries,
6 buildBulkTitleRegenerationContextMenuItem,6 buildBulkTitleRegenerationContextMenuItem,
7 buildBulkUnpinContextMenuItem,
7 buildMultiSelectThreadContextMenuItems,8 buildMultiSelectThreadContextMenuItems,
8 createThreadJumpHintVisibilityController,9 createThreadJumpHintVisibilityController,
9 filterSidebarProjectScopeItems,10 filterSidebarProjectScopeItems,
...@@ -185,6 +186,19 @@ describe("archiveSelectedThreadEntries", () => {...@@ -185,6 +186,19 @@ describe("archiveSelectedThreadEntries", () => {
185 });186 });
186});187});
187188
189describe("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
188describe("buildBulkTitleRegenerationContextMenuItem", () => {202describe("buildBulkTitleRegenerationContextMenuItem", () => {
189 it("counts only threads that can start a new regeneration", () => {203 it("counts only threads that can start a new regeneration", () => {
190 expect(204 expect(
apps/web/src/components/Sidebar.logic.ts+12
...@@ -177,6 +177,18 @@ export function buildBulkTitleRegenerationContextMenuItem(input: {...@@ -177,6 +177,18 @@ export function buildBulkTitleRegenerationContextMenuItem(input: {
177 };177 };
178}178}
179179
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 */
185export 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
180export interface ThreadStatusPill {192export interface ThreadStatusPill {
181 label:193 label:
182 | "Working"194 | "Working"
apps/web/src/components/Sidebar.tsx+22
...@@ -130,6 +130,7 @@ import { buildThreadActionMenuItems } from "./threadActionMenu.logic";...@@ -130,6 +130,7 @@ import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
130import {130import {
131 animatePinnedLayoutChanges,131 animatePinnedLayoutChanges,
132 buildBulkTitleRegenerationContextMenuItem,132 buildBulkTitleRegenerationContextMenuItem,
133 buildBulkUnpinContextMenuItem,
133 filterSidebarProjectScopeItems,134 filterSidebarProjectScopeItems,
134 formatWorkingDurationLabel,135 formatWorkingDurationLabel,
135 firstValidTimestampMs,136 firstValidTimestampMs,
...@@ -3025,10 +3026,22 @@ export default function Sidebar() {...@@ -3025,10 +3026,22 @@ export default function Sidebar() {
3025 supportedCount: titleRegenerationThreads.length,3026 supportedCount: titleRegenerationThreads.length,
3026 actionableCount: regeneratableTitleThreads.length,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 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);3040 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
3029 const clicked = await settlePromise(() =>3041 const clicked = await settlePromise(() =>
3030 api.contextMenu.show(3042 api.contextMenu.show(
3031 [3043 [
3044 ...(unpinMenuItem ? [unpinMenuItem] : []),
3032 { id: "settle", label: `Settle (${count})` },3045 { id: "settle", label: `Settle (${count})` },
3033 ...(canSnoozeSelection3046 ...(canSnoozeSelection
3034 ? [3047 ? [
...@@ -3110,6 +3123,14 @@ export default function Sidebar() {...@@ -3110,6 +3123,14 @@ export default function Sidebar() {
3110 }3123 }
3111 return;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 if (clicked.value === "regenerate-title") {3134 if (clicked.value === "regenerate-title") {
3114 for (const thread of regeneratableTitleThreads) {3135 for (const thread of regeneratableTitleThreads) {
3115 const result = await updateThreadMetadata({3136 const result = await updateThreadMetadata({
...@@ -3199,6 +3220,7 @@ export default function Sidebar() {...@@ -3199,6 +3220,7 @@ export default function Sidebar() {
3199 [3220 [
3200 attemptSettle,3221 attemptSettle,
3201 attemptSnooze,3222 attemptSnooze,
3223 attemptUnpin,
3202 clearSelection,3224 clearSelection,
3203 confirmThreadDelete,3225 confirmThreadDelete,
3204 deleteThread,3226 deleteThread,