| 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,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 it | 4106 | // 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 |
| 4 | 4 | ||
| 5 | const baseState: ThreadActionMenuState = { | 5 | const 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 | }); |
| 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 | 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 | */ |
| 9 | export type ThreadActionMenuId = | 9 | export 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 = |
| 28 | 29 | ||
| 29 | export interface ThreadActionMenuState { | 30 | export 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 { |
| 46 | 56 | ||
| 47 | /** | 57 | /** |
| 48 | * Single source for the per-thread action menu: the sidebar row's right-click | 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, | 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 | */ |
| 52 | export function buildThreadActionMenuItems( | 62 | export 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() }), |