diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 445ae533c323..c6bc0aa4a08a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3992,10 +3992,24 @@ export default function Sidebar() { const isPinned = thread.pinnedAt != null; // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const threadProjectGroup = + projectGroupsRef.current.find((project) => + project.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === thread.environmentId && + projectRef.projectId === thread.projectId, + ), + ) ?? null; const clicked = await settlePromise(() => api.contextMenu.show( buildThreadActionMenuItems({ branch: thread.branch ?? null, + projectFilter: threadProjectGroup + ? { + label: threadProjectGroup.displayName, + isActive: projectScopeKey === threadProjectGroup.projectKey, + } + : null, isPinned, isSettled, isSnoozed, @@ -4023,17 +4037,20 @@ export default function Sidebar() { return; } switch (clicked.value) { - case "project-settings": { - const projectGroup = projectGroupsRef.current.find((group) => - group.memberProjectRefs.some( - (projectRef) => - projectRef.environmentId === thread.environmentId && - projectRef.projectId === thread.projectId, - ), - ); - if (projectGroup) openProjectSettings(projectGroup); + case "filter-by-project": + // This item is the only scope control here, so picking the + // already-scoped project again is the way back to all projects. + if (threadProjectGroup) { + setProjectScopeKey( + projectScopeKey === threadProjectGroup.projectKey + ? null + : threadProjectGroup.projectKey, + ); + } + return; + case "project-settings": + if (threadProjectGroup) openProjectSettings(threadProjectGroup); return; - } case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -4194,8 +4211,10 @@ export default function Sidebar() { handleMultiSelectContextMenu, markThreadUnread, openProjectSettings, + projectScopeKey, projectByKey, serverConfigs, + setProjectScopeKey, startThreadRename, updateThreadMetadata, timestampFormat, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 1bdd04693759..9915a954943c 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -4,6 +4,7 @@ import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./thread const baseState: ThreadActionMenuState = { branch: null, + projectFilter: null, isPinned: false, isSettled: false, isSnoozed: false, @@ -47,6 +48,27 @@ describe("buildThreadActionMenuItems", () => { expect(items[copyIndex + 2]?.id).toBe("archive"); }); + it("offers project filtering only for surfaces with a scoped thread list", () => { + expect(ids(baseState)).not.toContain("filter-by-project"); + expect( + buildThreadActionMenuItems({ + ...baseState, + projectFilter: { label: "Beta Project", isActive: false }, + }).find((item) => item.id === "filter-by-project"), + ).toMatchObject({ label: "Filter by Beta Project", icon: "folder-tree" }); + }); + + it("offers the way back to all projects once the list is scoped", () => { + const items = buildThreadActionMenuItems({ + ...baseState, + projectFilter: { label: "Beta Project", isActive: true }, + }); + const filterIndex = items.findIndex((candidate) => candidate.id === "filter-by-project"); + expect(items[filterIndex]).toMatchObject({ label: "Show all projects", icon: "folder-tree" }); + expect(items[filterIndex - 1]?.id).toBe("mark-unread"); + expect(items[filterIndex + 1]?.id).toBe("copy"); + }); + it("includes branch items only for threads with a branch", () => { const withBranch = allIds({ ...baseState, branch: "feat/menu" }); expect(withBranch).toContain("new-thread-on-branch"); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 5ba266f7709d..4d9243a2092a 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -8,6 +8,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" */ export type ThreadActionMenuId = | "new-thread-on-branch" + | "filter-by-project" | "project-settings" | "pin" | "unpin" @@ -28,6 +29,15 @@ export type ThreadActionMenuId = export interface ThreadActionMenuState { readonly branch: string | null; + /** + * Project scoping for the thread list. Null on surfaces with no scoped + * list behind the menu (the chat header), where the item must not show. + */ + readonly projectFilter: { + readonly label: string; + /** True when the list is already scoped to this thread's project. */ + readonly isActive: boolean; + } | null; readonly isPinned: boolean; readonly isSettled: boolean; readonly isSnoozed: boolean; @@ -46,8 +56,8 @@ export interface ThreadActionMenuState { /** * Single source for the per-thread action menu: the sidebar row's right-click - * menu and the chat header menu both render exactly this list, so labels, - * ordering, and capability gating cannot drift between the two surfaces. + * menu and the chat header menu share labels, ordering, and capability gating. + * Each surface supplies state for the actions it supports. */ export function buildThreadActionMenuItems( state: ThreadActionMenuState, @@ -107,6 +117,17 @@ export function buildThreadActionMenuItems( ] : []), { id: "mark-unread", label: "Mark unread", icon: "mail-open" }, + ...(state.projectFilter + ? [ + { + id: "filter-by-project" as const, + label: state.projectFilter.isActive + ? "Show all projects" + : `Filter by ${state.projectFilter.label}`, + icon: "folder-tree", + }, + ] + : []), { id: "copy", label: "Copy", diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index a66ea21b9891..13641810e105 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -139,6 +139,9 @@ export function useThreadActionMenu(input: { const snoozePresets = resolveSnoozePresets(now, timestampFormat); const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, + // The chat header has no project-scoped thread list behind the + // menu, so the "Filter by project" affordance is sidebar-only. + projectFilter: null, isPinned: thread.pinnedAt != null, isSettled: supports.settlement && thread.settledOverride === "settled", isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),