authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-09-01 16:43:24-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 16:43:24-04:00
logcb007469161ff0db2bc2dc8123c4b30e186aae50
treedfc6a65a61b8e02470dbf7d9f88f8f662862dc59
parent9d1879b142a2f5d01383357646a4679d1a2bd202
signature Signed by PGP key B5690EEEBB952194

feat(web): open project settings from thread menus (#8925)


6 files changed, 105 insertions(+), 7 deletions(-)

apps/web/src/components/LegacySidebar.tsx+14
......@@ -2165,11 +2165,21 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
21652165 { id: "mark-unread", label: "Mark unread" },
21662166 { id: "copy-path", label: "Copy Path" },
21672167 { id: "copy-thread-id", label: "Copy Thread ID" },
2168 { id: "project-settings", label: "Project settings" },
21682169 { id: "delete", label: "Delete", destructive: true, icon: "trash" },
21692170 ],
21702171 position,
21712172 );
21722173
2174 if (clicked === "project-settings") {
2175 if (isMobile) setOpenMobile(false);
2176 void router.navigate({
2177 to: "/projects/$projectKey",
2178 params: { projectKey: project.projectKey },
2179 });
2180 return;
2181 }
2182
21732183 if (clicked === "new-thread-on-branch") {
21742184 // Explicit branch carry-over: reuse the thread's worktree when it
21752185 // has one, otherwise its branch on the local checkout.
......@@ -2252,9 +2262,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
22522262 copyThreadIdToClipboard,
22532263 deleteThread,
22542264 handleNewThread,
2265 isMobile,
22552266 markThreadUnread,
22562267 memberProjectByScopedKey,
2268 project.projectKey,
22572269 project.workspaceRoot,
2270 router,
2271 setOpenMobile,
22582272 startThreadRename,
22592273 ],
22602274 );
apps/web/src/components/Sidebar.tsx+25-5
......@@ -1888,6 +1888,8 @@ export default function Sidebar() {
18881888 () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder),
18891889 [sidebarProjectSortOrder, threads, unsortedProjectGroups],
18901890 );
1891 const projectGroupsRef = useRef(projectGroups);
1892 projectGroupsRef.current = projectGroups;
18911893 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
18921894 // Threads on non-primary environments (T3 Connect, hosted) resolve their
18931895 // provider entry from their own environment's config: default instance ids
......@@ -2043,11 +2045,8 @@ export default function Sidebar() {
20432045 clearSelection();
20442046 }, [clearSelection, projectScopeKey]);
20452047
2046 const handleProjectSettings = useCallback(
2047 (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => {
2048 event.preventDefault();
2049 event.stopPropagation();
2050 dispatchProjectScopeMenu({ type: "project-settings-opened" });
2048 const openProjectSettings = useCallback(
2049 (projectGroup: SidebarProjectSnapshot) => {
20512050 if (isMobile) {
20522051 setOpenMobile(false);
20532052 }
......@@ -2058,6 +2057,15 @@ export default function Sidebar() {
20582057 },
20592058 [isMobile, router, setOpenMobile],
20602059 );
2060 const handleProjectSettings = useCallback(
2061 (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => {
2062 event.preventDefault();
2063 event.stopPropagation();
2064 dispatchProjectScopeMenu({ type: "project-settings-opened" });
2065 openProjectSettings(projectGroup);
2066 },
2067 [openProjectSettings],
2068 );
20612069
20622070 // Settled threads stay in the live shell stream (settled ≠ archived), so
20632071 // the partition works directly off live shells: no archived-snapshot
......@@ -3122,6 +3130,17 @@ export default function Sidebar() {
31223130 return;
31233131 }
31243132 switch (clicked.value) {
3133 case "project-settings": {
3134 const projectGroup = projectGroupsRef.current.find((group) =>
3135 group.memberProjectRefs.some(
3136 (projectRef) =>
3137 projectRef.environmentId === thread.environmentId &&
3138 projectRef.projectId === thread.projectId,
3139 ),
3140 );
3141 if (projectGroup) openProjectSettings(projectGroup);
3142 return;
3143 }
31253144 case "new-thread-on-branch": {
31263145 // Explicit branch carry-over: reuse the thread's worktree when it
31273146 // has one, otherwise its branch on the local checkout.
......@@ -3281,6 +3300,7 @@ export default function Sidebar() {
32813300 deleteThread,
32823301 handleMultiSelectContextMenu,
32833302 markThreadUnread,
3303 openProjectSettings,
32843304 projectCwdByKey,
32853305 serverConfigs,
32863306 startThreadRename,
apps/web/src/components/threadActionMenu.logic.test.ts+12-1
......@@ -33,7 +33,18 @@ describe("buildThreadActionMenuItems", () => {
3333 ...baseState,
3434 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
3535 }),
36 ).toEqual(["rename", "mark-unread", "copy", "archive", "delete"]);
36 ).toEqual(["rename", "mark-unread", "copy", "project-settings", "archive", "delete"]);
37 });
38
39 it("groups project settings with utility actions before archive", () => {
40 const items = buildThreadActionMenuItems(baseState);
41 const copyIndex = items.findIndex((item) => item.id === "copy");
42 expect(items[copyIndex + 1]).toMatchObject({
43 id: "project-settings",
44 label: "Project settings",
45 icon: "settings",
46 });
47 expect(items[copyIndex + 2]?.id).toBe("archive");
3748 });
3849
3950 it("includes branch items only for threads with a branch", () => {
apps/web/src/components/threadActionMenu.logic.ts+2
......@@ -8,6 +8,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"
88 */
99export type ThreadActionMenuId =
1010 | "new-thread-on-branch"
11 | "project-settings"
1112 | "pin"
1213 | "unpin"
1314 | "settle"
......@@ -119,6 +120,7 @@ export function buildThreadActionMenuItems(
119120 { id: "copy-thread-id", label: "Thread ID", icon: "hash" },
120121 ],
121122 },
123 { id: "project-settings", label: "Project settings", icon: "settings" },
122124 // Archive removes the thread from the sidebar while keeping its
123125 // conversation under Settings > Archived threads — distinct from Settle
124126 // (stays visible in the Settled shelf) and Delete (clears history for
apps/web/src/contextMenuFallback.ts+9
......@@ -96,6 +96,15 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
9696 { tag: "path", attrs: { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" } },
9797 { tag: "path", attrs: { d: "M8 16H3v5" } },
9898 ],
99 settings: [
100 {
101 tag: "path",
102 attrs: {
103 d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.51a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z",
104 },
105 },
106 { tag: "circle", attrs: { cx: "12", cy: "12", r: "3" } },
107 ],
99108 "folder-tree": [
100109 {
101110 tag: "path",
apps/web/src/hooks/useThreadActionMenu.ts+43-1
......@@ -7,7 +7,8 @@ import {
77} from "@t3tools/client-runtime/state/runtime";
88import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
99import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
10import { useCallback } from "react";
10import { useRouter } from "@tanstack/react-router";
11import { useCallback, useMemo } from "react";
1112
1213import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze";
1314import {
......@@ -23,8 +24,16 @@ import {
2324 readEnvironmentSupportsSnooze,
2425 readEnvironmentSupportsTitleRegeneration,
2526 readThreadShell,
27 useProjects,
2628} from "../state/entities";
29import { usePrimaryEnvironmentId } from "../state/environments";
2730import { readLocalApi } from "../localApi";
31import {
32 deriveLogicalProjectKeyFromSettings,
33 derivePhysicalProjectKey,
34 selectProjectGroupingSettings,
35} from "../logicalProject";
36import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping";
2837import { useUiStateStore } from "../uiStateStore";
2938import { useCopyToClipboard } from "./useCopyToClipboard";
3039import { useNewThreadHandler } from "./useHandleNewThread";
......@@ -58,6 +67,19 @@ export function useThreadActionMenu(input: {
5867 readonly onStartRename: () => void;
5968}) {
6069 const { threadRef, projectCwd, onStartRename } = input;
70 const router = useRouter();
71 const projects = useProjects();
72 const primaryEnvironmentId = usePrimaryEnvironmentId();
73 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
74 const logicalProjectKeyByPhysicalKey = useMemo(
75 () =>
76 buildPhysicalToLogicalProjectKeyMap({
77 projects,
78 settings: projectGroupingSettings,
79 primaryEnvironmentId,
80 }),
81 [primaryEnvironmentId, projectGroupingSettings, projects],
82 );
6183 const {
6284 settleThread,
6385 unsettleThread,
......@@ -168,6 +190,22 @@ export function useThreadActionMenu(input: {
168190 }
169191 };
170192 switch (action) {
193 case "project-settings": {
194 const project = projects.find(
195 (candidate) =>
196 candidate.environmentId === thread.environmentId &&
197 candidate.id === thread.projectId,
198 );
199 if (!project) return;
200 const projectKey =
201 logicalProjectKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ??
202 deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings);
203 void router.navigate({
204 to: "/projects/$projectKey",
205 params: { projectKey },
206 });
207 return;
208 }
171209 case "new-thread-on-branch": {
172210 // Explicit branch carry-over: reuse the thread's worktree when it
173211 // has one, otherwise its branch on the local checkout.
......@@ -300,10 +338,14 @@ export function useThreadActionMenu(input: {
300338 copyThreadIdToClipboard,
301339 deleteThread,
302340 handleNewThread,
341 logicalProjectKeyByPhysicalKey,
303342 markThreadUnread,
304343 onStartRename,
305344 pinThread,
306345 projectCwd,
346 projectGroupingSettings,
347 projects,
348 router,
307349 settleThread,
308350 snoozeThread,
309351 threadRef,