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...@@ -2165,11 +2165,21 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2165 { id: "mark-unread", label: "Mark unread" },2165 { id: "mark-unread", label: "Mark unread" },
2166 { id: "copy-path", label: "Copy Path" },2166 { id: "copy-path", label: "Copy Path" },
2167 { id: "copy-thread-id", label: "Copy Thread ID" },2167 { id: "copy-thread-id", label: "Copy Thread ID" },
2168 { id: "project-settings", label: "Project settings" },
2168 { id: "delete", label: "Delete", destructive: true, icon: "trash" },2169 { id: "delete", label: "Delete", destructive: true, icon: "trash" },
2169 ],2170 ],
2170 position,2171 position,
2171 );2172 );
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
2173 if (clicked === "new-thread-on-branch") {2183 if (clicked === "new-thread-on-branch") {
2174 // Explicit branch carry-over: reuse the thread's worktree when it2184 // Explicit branch carry-over: reuse the thread's worktree when it
2175 // has one, otherwise its branch on the local checkout.2185 // has one, otherwise its branch on the local checkout.
...@@ -2252,9 +2262,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2252,9 +2262,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2252 copyThreadIdToClipboard,2262 copyThreadIdToClipboard,
2253 deleteThread,2263 deleteThread,
2254 handleNewThread,2264 handleNewThread,
2265 isMobile,
2255 markThreadUnread,2266 markThreadUnread,
2256 memberProjectByScopedKey,2267 memberProjectByScopedKey,
2268 project.projectKey,
2257 project.workspaceRoot,2269 project.workspaceRoot,
2270 router,
2271 setOpenMobile,
2258 startThreadRename,2272 startThreadRename,
2259 ],2273 ],
2260 );2274 );
apps/web/src/components/Sidebar.tsx+25-5
...@@ -1888,6 +1888,8 @@ export default function Sidebar() {...@@ -1888,6 +1888,8 @@ export default function Sidebar() {
1888 () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder),1888 () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder),
1889 [sidebarProjectSortOrder, threads, unsortedProjectGroups],1889 [sidebarProjectSortOrder, threads, unsortedProjectGroups],
1890 );1890 );
1891 const projectGroupsRef = useRef(projectGroups);
1892 projectGroupsRef.current = projectGroups;
1891 const serverConfigs = useAtomValue(environmentServerConfigsAtom);1893 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
1892 // Threads on non-primary environments (T3 Connect, hosted) resolve their1894 // Threads on non-primary environments (T3 Connect, hosted) resolve their
1893 // provider entry from their own environment's config: default instance ids1895 // provider entry from their own environment's config: default instance ids
...@@ -2043,11 +2045,8 @@ export default function Sidebar() {...@@ -2043,11 +2045,8 @@ export default function Sidebar() {
2043 clearSelection();2045 clearSelection();
2044 }, [clearSelection, projectScopeKey]);2046 }, [clearSelection, projectScopeKey]);
20452047
2046 const handleProjectSettings = useCallback(2048 const openProjectSettings = useCallback(
2047 (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => {2049 (projectGroup: SidebarProjectSnapshot) => {
2048 event.preventDefault();
2049 event.stopPropagation();
2050 dispatchProjectScopeMenu({ type: "project-settings-opened" });
2051 if (isMobile) {2050 if (isMobile) {
2052 setOpenMobile(false);2051 setOpenMobile(false);
2053 }2052 }
...@@ -2058,6 +2057,15 @@ export default function Sidebar() {...@@ -2058,6 +2057,15 @@ export default function Sidebar() {
2058 },2057 },
2059 [isMobile, router, setOpenMobile],2058 [isMobile, router, setOpenMobile],
2060 );2059 );
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
2062 // Settled threads stay in the live shell stream (settled ≠ archived), so2070 // Settled threads stay in the live shell stream (settled ≠ archived), so
2063 // the partition works directly off live shells: no archived-snapshot2071 // the partition works directly off live shells: no archived-snapshot
...@@ -3122,6 +3130,17 @@ export default function Sidebar() {...@@ -3122,6 +3130,17 @@ export default function Sidebar() {
3122 return;3130 return;
3123 }3131 }
3124 switch (clicked.value) {3132 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 }
3125 case "new-thread-on-branch": {3144 case "new-thread-on-branch": {
3126 // Explicit branch carry-over: reuse the thread's worktree when it3145 // Explicit branch carry-over: reuse the thread's worktree when it
3127 // has one, otherwise its branch on the local checkout.3146 // has one, otherwise its branch on the local checkout.
...@@ -3281,6 +3300,7 @@ export default function Sidebar() {...@@ -3281,6 +3300,7 @@ export default function Sidebar() {
3281 deleteThread,3300 deleteThread,
3282 handleMultiSelectContextMenu,3301 handleMultiSelectContextMenu,
3283 markThreadUnread,3302 markThreadUnread,
3303 openProjectSettings,
3284 projectCwdByKey,3304 projectCwdByKey,
3285 serverConfigs,3305 serverConfigs,
3286 startThreadRename,3306 startThreadRename,
apps/web/src/components/threadActionMenu.logic.test.ts+12-1
...@@ -33,7 +33,18 @@ describe("buildThreadActionMenuItems", () => {...@@ -33,7 +33,18 @@ describe("buildThreadActionMenuItems", () => {
33 ...baseState,33 ...baseState,
34 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },34 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
35 }),35 }),
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");
37 });48 });
3849
39 it("includes branch items only for threads with a branch", () => {50 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"...@@ -8,6 +8,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"
8 */8 */
9export type ThreadActionMenuId =9export type ThreadActionMenuId =
10 | "new-thread-on-branch"10 | "new-thread-on-branch"
11 | "project-settings"
11 | "pin"12 | "pin"
12 | "unpin"13 | "unpin"
13 | "settle"14 | "settle"
...@@ -119,6 +120,7 @@ export function buildThreadActionMenuItems(...@@ -119,6 +120,7 @@ export function buildThreadActionMenuItems(
119 { id: "copy-thread-id", label: "Thread ID", icon: "hash" },120 { id: "copy-thread-id", label: "Thread ID", icon: "hash" },
120 ],121 ],
121 },122 },
123 { id: "project-settings", label: "Project settings", icon: "settings" },
122 // Archive removes the thread from the sidebar while keeping its124 // Archive removes the thread from the sidebar while keeping its
123 // conversation under Settings > Archived threads — distinct from Settle125 // conversation under Settings > Archived threads — distinct from Settle
124 // (stays visible in the Settled shelf) and Delete (clears history for126 // (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...@@ -96,6 +96,15 @@ const ICON_PATHS: Record<string, ReadonlyArray<{ tag: string; attrs: Record<stri
96 { tag: "path", attrs: { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" } },96 { tag: "path", attrs: { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" } },
97 { tag: "path", attrs: { d: "M8 16H3v5" } },97 { tag: "path", attrs: { d: "M8 16H3v5" } },
98 ],98 ],
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 ],
99 "folder-tree": [108 "folder-tree": [
100 {109 {
101 tag: "path",110 tag: "path",
apps/web/src/hooks/useThreadActionMenu.ts+43-1
...@@ -7,7 +7,8 @@ import {...@@ -7,7 +7,8 @@ import {
7} from "@t3tools/client-runtime/state/runtime";7} from "@t3tools/client-runtime/state/runtime";
8import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";8import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
9import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";9import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
10import { useCallback } from "react";10import { useRouter } from "@tanstack/react-router";
11import { useCallback, useMemo } from "react";
1112
12import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze";13import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze";
13import {14import {
...@@ -23,8 +24,16 @@ import {...@@ -23,8 +24,16 @@ import {
23 readEnvironmentSupportsSnooze,24 readEnvironmentSupportsSnooze,
24 readEnvironmentSupportsTitleRegeneration,25 readEnvironmentSupportsTitleRegeneration,
25 readThreadShell,26 readThreadShell,
27 useProjects,
26} from "../state/entities";28} from "../state/entities";
29import { usePrimaryEnvironmentId } from "../state/environments";
27import { readLocalApi } from "../localApi";30import { readLocalApi } from "../localApi";
31import {
32 deriveLogicalProjectKeyFromSettings,
33 derivePhysicalProjectKey,
34 selectProjectGroupingSettings,
35} from "../logicalProject";
36import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping";
28import { useUiStateStore } from "../uiStateStore";37import { useUiStateStore } from "../uiStateStore";
29import { useCopyToClipboard } from "./useCopyToClipboard";38import { useCopyToClipboard } from "./useCopyToClipboard";
30import { useNewThreadHandler } from "./useHandleNewThread";39import { useNewThreadHandler } from "./useHandleNewThread";
...@@ -58,6 +67,19 @@ export function useThreadActionMenu(input: {...@@ -58,6 +67,19 @@ export function useThreadActionMenu(input: {
58 readonly onStartRename: () => void;67 readonly onStartRename: () => void;
59}) {68}) {
60 const { threadRef, projectCwd, onStartRename } = input;69 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 );
61 const {83 const {
62 settleThread,84 settleThread,
63 unsettleThread,85 unsettleThread,
...@@ -168,6 +190,22 @@ export function useThreadActionMenu(input: {...@@ -168,6 +190,22 @@ export function useThreadActionMenu(input: {
168 }190 }
169 };191 };
170 switch (action) {192 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 }
171 case "new-thread-on-branch": {209 case "new-thread-on-branch": {
172 // Explicit branch carry-over: reuse the thread's worktree when it210 // Explicit branch carry-over: reuse the thread's worktree when it
173 // has one, otherwise its branch on the local checkout.211 // has one, otherwise its branch on the local checkout.
...@@ -300,10 +338,14 @@ export function useThreadActionMenu(input: {...@@ -300,10 +338,14 @@ export function useThreadActionMenu(input: {
300 copyThreadIdToClipboard,338 copyThreadIdToClipboard,
301 deleteThread,339 deleteThread,
302 handleNewThread,340 handleNewThread,
341 logicalProjectKeyByPhysicalKey,
303 markThreadUnread,342 markThreadUnread,
304 onStartRename,343 onStartRename,
305 pinThread,344 pinThread,
306 projectCwd,345 projectCwd,
346 projectGroupingSettings,
347 projects,
348 router,
307 settleThread,349 settleThread,
308 snoozeThread,350 snoozeThread,
309 threadRef,351 threadRef,