authorgravatar for saphid@gmail.comAlex Southwell <saphid@gmail.com> 2026-09-18 10:28:06+10:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-17 17:28:06-07:00
log256e630ef7b8372f70f80590e85aada62da821ab
tree89afa4b5873d9dc4f386a866b04db938e628d360
parent3fd21df62da3191f1abf12a0cd74df6de9ed30a2
signature Signed by PGP key B5690EEEBB952194

feat(web): filter sidebar from thread menu (#8719)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

4 files changed, 77 insertions(+), 12 deletions(-)

apps/web/src/components/Sidebar.tsx+29-10
......@@ -4042,10 +4042,24 @@ export default function Sidebar() {
40424042 const isPinned = thread.pinnedAt != null;
40434043 // Presets resolve at menu-open time (same as the popover).
40444044 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
4045 const threadProjectGroup =
4046 projectGroupsRef.current.find((project) =>
4047 project.memberProjectRefs.some(
4048 (projectRef) =>
4049 projectRef.environmentId === thread.environmentId &&
4050 projectRef.projectId === thread.projectId,
4051 ),
4052 ) ?? null;
40454053 const clicked = await settlePromise(() =>
40464054 api.contextMenu.show(
40474055 buildThreadActionMenuItems({
40484056 branch: thread.branch ?? null,
4057 projectFilter: threadProjectGroup
4058 ? {
4059 label: threadProjectGroup.displayName,
4060 isActive: projectScopeKey === threadProjectGroup.projectKey,
4061 }
4062 : null,
40494063 isPinned,
40504064 isSettled,
40514065 isSnoozed,
......@@ -4074,17 +4088,20 @@ export default function Sidebar() {
40744088 return;
40754089 }
40764090 switch (clicked.value) {
4077 case "project-settings": {
4078 const projectGroup = projectGroupsRef.current.find((group) =>
4079 group.memberProjectRefs.some(
4080 (projectRef) =>
4081 projectRef.environmentId === thread.environmentId &&
4082 projectRef.projectId === thread.projectId,
4083 ),
4084 );
4085 if (projectGroup) openProjectSettings(projectGroup);
4091 case "filter-by-project":
4092 // This item is the only scope control here, so picking the
4093 // already-scoped project again is the way back to all projects.
4094 if (threadProjectGroup) {
4095 setProjectScopeKey(
4096 projectScopeKey === threadProjectGroup.projectKey
4097 ? null
4098 : threadProjectGroup.projectKey,
4099 );
4100 }
4101 return;
4102 case "project-settings":
4103 if (threadProjectGroup) openProjectSettings(threadProjectGroup);
40864104 return;
4087 }
40884105 case "new-thread-on-branch": {
40894106 // Explicit branch carry-over: reuse the thread's worktree when it
40904107 // has one, otherwise its branch on the local checkout.
......@@ -4245,8 +4262,10 @@ export default function Sidebar() {
42454262 handleMultiSelectContextMenu,
42464263 markThreadUnread,
42474264 openProjectSettings,
4265 projectScopeKey,
42484266 projectByKey,
42494267 serverConfigs,
4268 setProjectScopeKey,
42504269 startThreadRename,
42514270 updateThreadMetadata,
42524271 timestampFormat,
apps/web/src/components/threadActionMenu.logic.test.ts+22
......@@ -4,6 +4,7 @@ import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./thread
44
55const baseState: ThreadActionMenuState = {
66 branch: null,
7 projectFilter: null,
78 isPinned: false,
89 isSettled: false,
910 isSnoozed: false,
......@@ -47,6 +48,27 @@ describe("buildThreadActionMenuItems", () => {
4748 expect(items[copyIndex + 2]?.id).toBe("archive");
4849 });
4950
51 it("offers project filtering only for surfaces with a scoped thread list", () => {
52 expect(ids(baseState)).not.toContain("filter-by-project");
53 expect(
54 buildThreadActionMenuItems({
55 ...baseState,
56 projectFilter: { label: "Beta Project", isActive: false },
57 }).find((item) => item.id === "filter-by-project"),
58 ).toMatchObject({ label: "Filter by Beta Project", icon: "folder-tree" });
59 });
60
61 it("offers the way back to all projects once the list is scoped", () => {
62 const items = buildThreadActionMenuItems({
63 ...baseState,
64 projectFilter: { label: "Beta Project", isActive: true },
65 });
66 const filterIndex = items.findIndex((candidate) => candidate.id === "filter-by-project");
67 expect(items[filterIndex]).toMatchObject({ label: "Show all projects", icon: "folder-tree" });
68 expect(items[filterIndex - 1]?.id).toBe("mark-unread");
69 expect(items[filterIndex + 1]?.id).toBe("copy");
70 });
71
5072 it("includes branch items only for threads with a branch", () => {
5173 const withBranch = allIds({ ...baseState, branch: "feat/menu" });
5274 expect(withBranch).toContain("new-thread-on-branch");
apps/web/src/components/threadActionMenu.logic.ts+23-2
......@@ -8,6 +8,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"
88 */
99export type ThreadActionMenuId =
1010 | "new-thread-on-branch"
11 | "filter-by-project"
1112 | "project-settings"
1213 | "pin"
1314 | "unpin"
......@@ -28,6 +29,15 @@ export type ThreadActionMenuId =
2829
2930export interface ThreadActionMenuState {
3031 readonly branch: string | null;
32 /**
33 * Project scoping for the thread list. Null on surfaces with no scoped
34 * list behind the menu (the chat header), where the item must not show.
35 */
36 readonly projectFilter: {
37 readonly label: string;
38 /** True when the list is already scoped to this thread's project. */
39 readonly isActive: boolean;
40 } | null;
3141 readonly isPinned: boolean;
3242 readonly isSettled: boolean;
3343 readonly isSnoozed: boolean;
......@@ -46,8 +56,8 @@ export interface ThreadActionMenuState {
4656
4757/**
4858 * Single source for the per-thread action menu: the sidebar row's right-click
49 * menu and the chat header menu both render exactly this list, so labels,
50 * ordering, and capability gating cannot drift between the two surfaces.
59 * menu and the chat header menu share labels, ordering, and capability gating.
60 * Each surface supplies state for the actions it supports.
5161 */
5262export function buildThreadActionMenuItems(
5363 state: ThreadActionMenuState,
......@@ -110,6 +120,17 @@ export function buildThreadActionMenuItems(
110120 ]
111121 : []),
112122 { id: "mark-unread", label: "Mark unread", icon: "mail-open" },
123 ...(state.projectFilter
124 ? [
125 {
126 id: "filter-by-project" as const,
127 label: state.projectFilter.isActive
128 ? "Show all projects"
129 : `Filter by ${state.projectFilter.label}`,
130 icon: "folder-tree",
131 },
132 ]
133 : []),
113134 {
114135 id: "copy",
115136 label: "Copy",
apps/web/src/hooks/useThreadActionMenu.ts+3
......@@ -140,6 +140,9 @@ export function useThreadActionMenu(input: {
140140 const snoozePresets = resolveSnoozePresets(now, timestampFormat);
141141 const items = buildThreadActionMenuItems({
142142 branch: thread.branch ?? null,
143 // The chat header has no project-scoped thread list behind the
144 // menu, so the "Filter by project" affordance is sidebar-only.
145 projectFilter: null,
143146 isPinned: thread.pinnedAt != null,
144147 isSettled: supports.settlement && thread.settledOverride === "settled",
145148 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),