| author | |
| committer | |
| log | 256e630ef7b8372f70f80590e85aada62da821ab |
| tree | 89afa4b5873d9dc4f386a866b04db938e628d360 |
| parent | 3fd21df62da3191f1abf12a0cd74df6de9ed30a2 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 4042 | const isPinned = thread.pinnedAt != null; |
| 4043 | 4043 | // Presets resolve at menu-open time (same as the popover). |
| 4044 | 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 | 4053 | const clicked = await settlePromise(() => |
| 4046 | 4054 | api.contextMenu.show( |
| 4047 | 4055 | buildThreadActionMenuItems({ |
| 4048 | 4056 | branch: thread.branch ?? null, |
| 4057 | projectFilter: threadProjectGroup | |
| 4058 | ? { | |
| 4059 | label: threadProjectGroup.displayName, | |
| 4060 | isActive: projectScopeKey === threadProjectGroup.projectKey, | |
| 4061 | } | |
| 4062 | : null, | |
| 4049 | 4063 | isPinned, |
| 4050 | 4064 | isSettled, |
| 4051 | 4065 | isSnoozed, |
| ... | ... | @@ -4074,17 +4088,20 @@ export default function Sidebar() { |
| 4074 | 4088 | return; |
| 4075 | 4089 | } |
| 4076 | 4090 | 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); | |
| 4086 | 4104 | return; |
| 4087 | } | |
| 4088 | 4105 | case "new-thread-on-branch": { |
| 4089 | 4106 | // Explicit branch carry-over: reuse the thread's worktree when it |
| 4090 | 4107 | // has one, otherwise its branch on the local checkout. |
| ... | ... | @@ -4245,8 +4262,10 @@ export default function Sidebar() { |
| 4245 | 4262 | handleMultiSelectContextMenu, |
| 4246 | 4263 | markThreadUnread, |
| 4247 | 4264 | openProjectSettings, |
| 4265 | projectScopeKey, | |
| 4248 | 4266 | projectByKey, |
| 4249 | 4267 | serverConfigs, |
| 4268 | setProjectScopeKey, | |
| 4250 | 4269 | startThreadRename, |
| 4251 | 4270 | updateThreadMetadata, |
| 4252 | 4271 | timestampFormat, |
apps/web/src/components/threadActionMenu.logic.test.ts+22| ... | ... | @@ -4,6 +4,7 @@ import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./thread |
| 4 | 4 | |
| 5 | 5 | const baseState: ThreadActionMenuState = { |
| 6 | 6 | branch: null, |
| 7 | projectFilter: null, | |
| 7 | 8 | isPinned: false, |
| 8 | 9 | isSettled: false, |
| 9 | 10 | isSnoozed: false, |
| ... | ... | @@ -47,6 +48,27 @@ describe("buildThreadActionMenuItems", () => { |
| 47 | 48 | expect(items[copyIndex + 2]?.id).toBe("archive"); |
| 48 | 49 | }); |
| 49 | 50 | |
| 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 | 72 | it("includes branch items only for threads with a branch", () => { |
| 51 | 73 | const withBranch = allIds({ ...baseState, branch: "feat/menu" }); |
| 52 | 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 | 8 | */ |
| 9 | 9 | export type ThreadActionMenuId = |
| 10 | 10 | | "new-thread-on-branch" |
| 11 | | "filter-by-project" | |
| 11 | 12 | | "project-settings" |
| 12 | 13 | | "pin" |
| 13 | 14 | | "unpin" |
| ... | ... | @@ -28,6 +29,15 @@ export type ThreadActionMenuId = |
| 28 | 29 | |
| 29 | 30 | export interface ThreadActionMenuState { |
| 30 | 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 | 41 | readonly isPinned: boolean; |
| 32 | 42 | readonly isSettled: boolean; |
| 33 | 43 | readonly isSnoozed: boolean; |
| ... | ... | @@ -46,8 +56,8 @@ export interface ThreadActionMenuState { |
| 46 | 56 | |
| 47 | 57 | /** |
| 48 | 58 | * 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. | |
| 51 | 61 | */ |
| 52 | 62 | export function buildThreadActionMenuItems( |
| 53 | 63 | state: ThreadActionMenuState, |
| ... | ... | @@ -110,6 +120,17 @@ export function buildThreadActionMenuItems( |
| 110 | 120 | ] |
| 111 | 121 | : []), |
| 112 | 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 | 135 | id: "copy", |
| 115 | 136 | label: "Copy", |
apps/web/src/hooks/useThreadActionMenu.ts+3| ... | ... | @@ -140,6 +140,9 @@ export function useThreadActionMenu(input: { |
| 140 | 140 | const snoozePresets = resolveSnoozePresets(now, timestampFormat); |
| 141 | 141 | const items = buildThreadActionMenuItems({ |
| 142 | 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 | 146 | isPinned: thread.pinnedAt != null, |
| 144 | 147 | isSettled: supports.settlement && thread.settledOverride === "settled", |
| 145 | 148 | isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), |