Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -4194,8 +4211,10 @@ export default function Sidebar() {
handleMultiSelectContextMenu,
markThreadUnread,
openProjectSettings,
projectScopeKey,
projectByKey,
serverConfigs,
setProjectScopeKey,
startThreadRename,
updateThreadMetadata,
timestampFormat,
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/components/threadActionMenu.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./thread

const baseState: ThreadActionMenuState = {
branch: null,
projectFilter: null,
isPinned: false,
isSettled: false,
isSnoozed: false,
Expand Down Expand Up @@ -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");
Expand Down
25 changes: 23 additions & 2 deletions apps/web/src/components/threadActionMenu.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/hooks/useThreadActionMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }),
Expand Down
Loading