| author | |
| committer | |
| log | cb007469161ff0db2bc2dc8123c4b30e186aae50 |
| tree | dfc6a65a61b8e02470dbf7d9f88f8f662862dc59 |
| parent | 9d1879b142a2f5d01383357646a4679d1a2bd202 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 2172 | 2173 | ||
| 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 it | 2184 | // 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 their | 1894 | // Threads on non-primary environments (T3 Connect, hosted) resolve their |
| 1893 | // provider entry from their own environment's config: default instance ids | 1895 | // 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]); |
| 2045 | 2047 | ||
| 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 | ); | ||
| 2061 | 2069 | ||
| 2062 | // Settled threads stay in the live shell stream (settled ≠ archived), so | 2070 | // Settled threads stay in the live shell stream (settled ≠ archived), so |
| 2063 | // the partition works directly off live shells: no archived-snapshot | 2071 | // 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 it | 3145 | // 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 | }); |
| 38 | 49 | ||
| 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 | */ |
| 9 | export type ThreadActionMenuId = | 9 | export 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 its | 124 | // Archive removes the thread from the sidebar while keeping its |
| 123 | // conversation under Settings > Archived threads — distinct from Settle | 125 | // conversation under Settings > Archived threads — distinct from Settle |
| 124 | // (stays visible in the Settled shelf) and Delete (clears history for | 126 | // (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"; |
| 8 | import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; | 8 | import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; |
| 9 | import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | 9 | import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 10 | import { useCallback } from "react"; | 10 | import { useRouter } from "@tanstack/react-router"; |
| 11 | import { useCallback, useMemo } from "react"; | ||
| 11 | 12 | ||
| 12 | import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; | 13 | import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; |
| 13 | import { | 14 | import { |
| ... | @@ -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"; |
| 29 | import { usePrimaryEnvironmentId } from "../state/environments"; | ||
| 27 | import { readLocalApi } from "../localApi"; | 30 | import { readLocalApi } from "../localApi"; |
| 31 | import { | ||
| 32 | deriveLogicalProjectKeyFromSettings, | ||
| 33 | derivePhysicalProjectKey, | ||
| 34 | selectProjectGroupingSettings, | ||
| 35 | } from "../logicalProject"; | ||
| 36 | import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; | ||
| 28 | import { useUiStateStore } from "../uiStateStore"; | 37 | import { useUiStateStore } from "../uiStateStore"; |
| 29 | import { useCopyToClipboard } from "./useCopyToClipboard"; | 38 | import { useCopyToClipboard } from "./useCopyToClipboard"; |
| 30 | import { useNewThreadHandler } from "./useHandleNewThread"; | 39 | import { 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 it | 210 | // 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, |