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() {...@@ -4042,10 +4042,24 @@ export default function Sidebar() {
4042 const isPinned = thread.pinnedAt != null;4042 const isPinned = thread.pinnedAt != null;
4043 // Presets resolve at menu-open time (same as the popover).4043 // Presets resolve at menu-open time (same as the popover).
4044 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);4044 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;
4045 const clicked = await settlePromise(() =>4053 const clicked = await settlePromise(() =>
4046 api.contextMenu.show(4054 api.contextMenu.show(
4047 buildThreadActionMenuItems({4055 buildThreadActionMenuItems({
4048 branch: thread.branch ?? null,4056 branch: thread.branch ?? null,
4057 projectFilter: threadProjectGroup
4058 ? {
4059 label: threadProjectGroup.displayName,
4060 isActive: projectScopeKey === threadProjectGroup.projectKey,
4061 }
4062 : null,
4049 isPinned,4063 isPinned,
4050 isSettled,4064 isSettled,
4051 isSnoozed,4065 isSnoozed,
...@@ -4074,17 +4088,20 @@ export default function Sidebar() {...@@ -4074,17 +4088,20 @@ export default function Sidebar() {
4074 return;4088 return;
4075 }4089 }
4076 switch (clicked.value) {4090 switch (clicked.value) {
4077 case "project-settings": {4091 case "filter-by-project":
4078 const projectGroup = projectGroupsRef.current.find((group) =>4092 // This item is the only scope control here, so picking the
4079 group.memberProjectRefs.some(4093 // already-scoped project again is the way back to all projects.
4080 (projectRef) =>4094 if (threadProjectGroup) {
4081 projectRef.environmentId === thread.environmentId &&4095 setProjectScopeKey(
4082 projectRef.projectId === thread.projectId,4096 projectScopeKey === threadProjectGroup.projectKey
4083 ),4097 ? null
4084 );4098 : threadProjectGroup.projectKey,
4085 if (projectGroup) openProjectSettings(projectGroup);4099 );
4100 }
4101 return;
4102 case "project-settings":
4103 if (threadProjectGroup) openProjectSettings(threadProjectGroup);
4086 return;4104 return;
4087 }
4088 case "new-thread-on-branch": {4105 case "new-thread-on-branch": {
4089 // Explicit branch carry-over: reuse the thread's worktree when it4106 // Explicit branch carry-over: reuse the thread's worktree when it
4090 // has one, otherwise its branch on the local checkout.4107 // has one, otherwise its branch on the local checkout.
...@@ -4245,8 +4262,10 @@ export default function Sidebar() {...@@ -4245,8 +4262,10 @@ export default function Sidebar() {
4245 handleMultiSelectContextMenu,4262 handleMultiSelectContextMenu,
4246 markThreadUnread,4263 markThreadUnread,
4247 openProjectSettings,4264 openProjectSettings,
4265 projectScopeKey,
4248 projectByKey,4266 projectByKey,
4249 serverConfigs,4267 serverConfigs,
4268 setProjectScopeKey,
4250 startThreadRename,4269 startThreadRename,
4251 updateThreadMetadata,4270 updateThreadMetadata,
4252 timestampFormat,4271 timestampFormat,
apps/web/src/components/threadActionMenu.logic.test.ts+22
...@@ -4,6 +4,7 @@ import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./thread...@@ -4,6 +4,7 @@ import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./thread
44
5const baseState: ThreadActionMenuState = {5const baseState: ThreadActionMenuState = {
6 branch: null,6 branch: null,
7 projectFilter: null,
7 isPinned: false,8 isPinned: false,
8 isSettled: false,9 isSettled: false,
9 isSnoozed: false,10 isSnoozed: false,
...@@ -47,6 +48,27 @@ describe("buildThreadActionMenuItems", () => {...@@ -47,6 +48,27 @@ describe("buildThreadActionMenuItems", () => {
47 expect(items[copyIndex + 2]?.id).toBe("archive");48 expect(items[copyIndex + 2]?.id).toBe("archive");
48 });49 });
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
50 it("includes branch items only for threads with a branch", () => {72 it("includes branch items only for threads with a branch", () => {
51 const withBranch = allIds({ ...baseState, branch: "feat/menu" });73 const withBranch = allIds({ ...baseState, branch: "feat/menu" });
52 expect(withBranch).toContain("new-thread-on-branch");74 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"...@@ -8,6 +8,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"
8 */8 */
9export type ThreadActionMenuId =9export type ThreadActionMenuId =
10 | "new-thread-on-branch"10 | "new-thread-on-branch"
11 | "filter-by-project"
11 | "project-settings"12 | "project-settings"
12 | "pin"13 | "pin"
13 | "unpin"14 | "unpin"
...@@ -28,6 +29,15 @@ export type ThreadActionMenuId =...@@ -28,6 +29,15 @@ export type ThreadActionMenuId =
2829
29export interface ThreadActionMenuState {30export interface ThreadActionMenuState {
30 readonly branch: string | null;31 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;
31 readonly isPinned: boolean;41 readonly isPinned: boolean;
32 readonly isSettled: boolean;42 readonly isSettled: boolean;
33 readonly isSnoozed: boolean;43 readonly isSnoozed: boolean;
...@@ -46,8 +56,8 @@ export interface ThreadActionMenuState {...@@ -46,8 +56,8 @@ export interface ThreadActionMenuState {
4656
47/**57/**
48 * Single source for the per-thread action menu: the sidebar row's right-click58 * 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,59 * menu and the chat header menu share labels, ordering, and capability gating.
50 * ordering, and capability gating cannot drift between the two surfaces.60 * Each surface supplies state for the actions it supports.
51 */61 */
52export function buildThreadActionMenuItems(62export function buildThreadActionMenuItems(
53 state: ThreadActionMenuState,63 state: ThreadActionMenuState,
...@@ -110,6 +120,17 @@ export function buildThreadActionMenuItems(...@@ -110,6 +120,17 @@ export function buildThreadActionMenuItems(
110 ]120 ]
111 : []),121 : []),
112 { id: "mark-unread", label: "Mark unread", icon: "mail-open" },122 { 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 : []),
113 {134 {
114 id: "copy",135 id: "copy",
115 label: "Copy",136 label: "Copy",
apps/web/src/hooks/useThreadActionMenu.ts+3
...@@ -140,6 +140,9 @@ export function useThreadActionMenu(input: {...@@ -140,6 +140,9 @@ export function useThreadActionMenu(input: {
140 const snoozePresets = resolveSnoozePresets(now, timestampFormat);140 const snoozePresets = resolveSnoozePresets(now, timestampFormat);
141 const items = buildThreadActionMenuItems({141 const items = buildThreadActionMenuItems({
142 branch: thread.branch ?? null,142 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,
143 isPinned: thread.pinnedAt != null,146 isPinned: thread.pinnedAt != null,
144 isSettled: supports.settlement && thread.settledOverride === "settled",147 isSettled: supports.settlement && thread.settledOverride === "settled",
145 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),148 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),