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 {
44 animatePinnedLayoutChanges,
55 archiveSelectedThreadEntries,
66 buildBulkTitleRegenerationContextMenuItem,
7 buildBulkUnpinContextMenuItem,
78 buildMultiSelectThreadContextMenuItems,
89 createThreadJumpHintVisibilityController,
910 filterSidebarProjectScopeItems,
......@@ -185,6 +186,19 @@ describe("archiveSelectedThreadEntries", () => {
185186 });
186187});
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
188202describe("buildBulkTitleRegenerationContextMenuItem", () => {
189203 it("counts only threads that can start a new regeneration", () => {
190204 expect(
apps/web/src/components/Sidebar.logic.ts+12
......@@ -177,6 +177,18 @@ export function buildBulkTitleRegenerationContextMenuItem(input: {
177177 };
178178}
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
180192export interface ThreadStatusPill {
181193 label:
182194 | "Working"
apps/web/src/components/Sidebar.tsx+22
......@@ -130,6 +130,7 @@ import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
130130import {
131131 animatePinnedLayoutChanges,
132132 buildBulkTitleRegenerationContextMenuItem,
133 buildBulkUnpinContextMenuItem,
133134 filterSidebarProjectScopeItems,
134135 formatWorkingDurationLabel,
135136 firstValidTimestampMs,
......@@ -3025,10 +3026,22 @@ export default function Sidebar() {
30253026 supportedCount: titleRegenerationThreads.length,
30263027 actionableCount: regeneratableTitleThreads.length,
30273028 });
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 });
30283040 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
30293041 const clicked = await settlePromise(() =>
30303042 api.contextMenu.show(
30313043 [
3044 ...(unpinMenuItem ? [unpinMenuItem] : []),
30323045 { id: "settle", label: `Settle (${count})` },
30333046 ...(canSnoozeSelection
30343047 ? [
......@@ -3110,6 +3123,14 @@ export default function Sidebar() {
31103123 }
31113124 return;
31123125 }
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 }
31133134 if (clicked.value === "regenerate-title") {
31143135 for (const thread of regeneratableTitleThreads) {
31153136 const result = await updateThreadMetadata({
......@@ -3199,6 +3220,7 @@ export default function Sidebar() {
31993220 [
32003221 attemptSettle,
32013222 attemptSnooze,
3223 attemptUnpin,
32023224 clearSelection,
32033225 confirmThreadDelete,
32043226 deleteThread,