authorgravatar for 121109260+tlh38@users.noreply.github.comTristan <121109260+tlh38@users.noreply.github.com> 2026-04-16 19:29:25-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-16 16:29:25-07:00
log188a40c315bceaeec6237f05ae253941ad6bb4dc
treeac831ecf94634ff138b896f38bb58953c9266e57
parent7a08fcf2e832da5969aeb373c46d5249b4e820b7
signature Signed by PGP key B5690EEEBB952194

feat: configurable project grouping (#2055)

Co-authored-by: tlh38 <tlh38@users.noreply.github.com> Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

21 files changed, 1592 insertions(+), 345 deletions(-)

apps/desktop/src/clientPersistence.test.ts+4
......@@ -52,6 +52,10 @@ const clientSettings: ClientSettings = {
5252 confirmThreadArchive: true,
5353 confirmThreadDelete: false,
5454 diffWordWrap: true,
55 sidebarProjectGroupingMode: "repository_path",
56 sidebarProjectGroupingOverrides: {
57 "environment-1:/tmp/project-a": "separate",
58 },
5559 sidebarProjectSortOrder: "manual",
5660 sidebarThreadSortOrder: "created_at",
5761 timestampFormat: "24-hour",
apps/desktop/src/main.ts+58-27
......@@ -160,6 +160,35 @@ const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linu
160160const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
161161const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";
162162
163function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] {
164 const normalizedItems: ContextMenuItem[] = [];
165
166 for (const sourceItem of source) {
167 if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") {
168 continue;
169 }
170
171 const normalizedItem: ContextMenuItem = {
172 id: sourceItem.id,
173 label: sourceItem.label,
174 destructive: sourceItem.destructive === true,
175 disabled: sourceItem.disabled === true,
176 };
177
178 if (sourceItem.children) {
179 const normalizedChildren = normalizeContextMenuItems(sourceItem.children);
180 if (normalizedChildren.length === 0) {
181 continue;
182 }
183 normalizedItem.children = normalizedChildren;
184 }
185
186 normalizedItems.push(normalizedItem);
187 }
188
189 return normalizedItems;
190}
191
163192type WindowTitleBarOptions = Pick<
164193 BrowserWindowConstructorOptions,
165194 "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition"
......@@ -1715,14 +1744,7 @@ function registerIpcHandlers(): void {
17151744 ipcMain.handle(
17161745 CONTEXT_MENU_CHANNEL,
17171746 async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => {
1718 const normalizedItems = items
1719 .filter((item) => typeof item.id === "string" && typeof item.label === "string")
1720 .map((item) => ({
1721 id: item.id,
1722 label: item.label,
1723 destructive: item.destructive === true,
1724 disabled: item.disabled === true,
1725 }));
1747 const normalizedItems = normalizeContextMenuItems(items);
17261748 if (normalizedItems.length === 0) {
17271749 return null;
17281750 }
......@@ -1743,28 +1765,37 @@ function registerIpcHandlers(): void {
17431765 if (!window) return null;
17441766
17451767 return new Promise<string | null>((resolve) => {
1746 const template: MenuItemConstructorOptions[] = [];
1747 let hasInsertedDestructiveSeparator = false;
1748 for (const item of normalizedItems) {
1749 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
1750 template.push({ type: "separator" });
1751 hasInsertedDestructiveSeparator = true;
1752 }
1753 const itemOption: MenuItemConstructorOptions = {
1754 label: item.label,
1755 enabled: !item.disabled,
1756 click: () => resolve(item.id),
1757 };
1758 if (item.destructive) {
1759 const destructiveIcon = getDestructiveMenuIcon();
1760 if (destructiveIcon) {
1761 itemOption.icon = destructiveIcon;
1768 const buildTemplate = (
1769 entries: readonly ContextMenuItem[],
1770 ): MenuItemConstructorOptions[] => {
1771 const template: MenuItemConstructorOptions[] = [];
1772 let hasInsertedDestructiveSeparator = false;
1773 for (const item of entries) {
1774 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
1775 template.push({ type: "separator" });
1776 hasInsertedDestructiveSeparator = true;
1777 }
1778 const itemOption: MenuItemConstructorOptions = {
1779 label: item.label,
1780 enabled: !item.disabled,
1781 };
1782 if (item.children && item.children.length > 0) {
1783 itemOption.submenu = buildTemplate(item.children);
1784 } else {
1785 itemOption.click = () => resolve(item.id);
17621786 }
1787 if (item.destructive && (!item.children || item.children.length === 0)) {
1788 const destructiveIcon = getDestructiveMenuIcon();
1789 if (destructiveIcon) {
1790 itemOption.icon = destructiveIcon;
1791 }
1792 }
1793 template.push(itemOption);
17631794 }
1764 template.push(itemOption);
1765 }
1795 return template;
1796 };
17661797
1767 const menu = Menu.buildFromTemplate(template);
1798 const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems));
17681799 menu.popup({
17691800 window,
17701801 ...popupPosition,
apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts+28
......@@ -1,3 +1,5 @@
1import { realpathSync } from "node:fs";
2
13import * as NodeServices from "@effect/platform-node/NodeServices";
24import { expect, it } from "@effect/vitest";
35import { Duration, Effect, FileSystem, Layer } from "effect";
......@@ -10,6 +12,10 @@ import {
1012 RepositoryIdentityResolverLive,
1113} from "./RepositoryIdentityResolver.ts";
1214
15const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/");
16const normalizeResolvedPath = (value: string) =>
17 normalizePathSeparators(realpathSync.native(value));
18
1319const git = (cwd: string, args: ReadonlyArray<string>) =>
1420 Effect.promise(() => runProcess("git", ["-C", cwd, ...args]));
1521
......@@ -41,6 +47,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
4147
4248 expect(identity).not.toBeNull();
4349 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
50 expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(cwd));
4451 expect(identity?.displayName).toBe("t3tools/t3code");
4552 expect(identity?.provider).toBe("github");
4653 expect(identity?.owner).toBe("t3tools");
......@@ -48,6 +55,27 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
4855 }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
4956 );
5057
58 it.effect("returns the git top-level root path when resolving from a nested workspace", () =>
59 Effect.gen(function* () {
60 const fileSystem = yield* FileSystem.FileSystem;
61 const repoRoot = yield* fileSystem.makeTempDirectoryScoped({
62 prefix: "t3-repository-identity-nested-root-test-",
63 });
64 const nestedWorkspace = `${repoRoot}/packages/web`;
65
66 yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true });
67 yield* git(repoRoot, ["init"]);
68 yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
69
70 const resolver = yield* RepositoryIdentityResolver;
71 const identity = yield* resolver.resolve(nestedWorkspace);
72
73 expect(identity).not.toBeNull();
74 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
75 expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(repoRoot));
76 }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
77 );
78
5179 it.effect("returns null for non-git folders and repos without remotes", () =>
5280 Effect.gen(function* () {
5381 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/project/Layers/RepositoryIdentityResolver.ts+3-1
......@@ -42,6 +42,7 @@ function pickPrimaryRemote(
4242function buildRepositoryIdentity(input: {
4343 readonly remoteName: string;
4444 readonly remoteUrl: string;
45 readonly rootPath: string;
4546}): RepositoryIdentity {
4647 const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl);
4748 const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl);
......@@ -57,6 +58,7 @@ function buildRepositoryIdentity(input: {
5758 remoteName: input.remoteName,
5859 remoteUrl: input.remoteUrl,
5960 },
61 rootPath: input.rootPath,
6062 ...(repositoryPath ? { displayName: repositoryPath } : {}),
6163 ...(hostingProvider ? { provider: hostingProvider.kind } : {}),
6264 ...(owner ? { owner } : {}),
......@@ -108,7 +110,7 @@ async function resolveRepositoryIdentityFromCacheKey(
108110 }
109111
110112 const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout));
111 return remote ? buildRepositoryIdentity(remote) : null;
113 return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null;
112114 } catch {
113115 return null;
114116 }
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+4-4
......@@ -351,7 +351,7 @@ describe("ClaudeAdapterLive", () => {
351351 );
352352 });
353353
354 it.effect("defaults Claude Opus 4.7 sessions to xhigh effort", () => {
354 it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => {
355355 const harness = makeHarness();
356356 return Effect.gen(function* () {
357357 const adapter = yield* ClaudeAdapter;
......@@ -366,14 +366,14 @@ describe("ClaudeAdapterLive", () => {
366366 });
367367
368368 const createInput = harness.getLastCreateQueryInput();
369 assert.equal(createInput?.options.effort, "xhigh");
369 assert.equal(createInput?.options.effort, "max");
370370 }).pipe(
371371 Effect.provideService(Random.Random, makeDeterministicRandomService()),
372372 Effect.provide(harness.layer),
373373 );
374374 });
375375
376 it.effect("forwards xhigh effort for Claude Opus 4.7", () => {
376 it.effect("maps xhigh effort for Claude Opus 4.7 to the SDK-supported max value", () => {
377377 const harness = makeHarness();
378378 return Effect.gen(function* () {
379379 const adapter = yield* ClaudeAdapter;
......@@ -391,7 +391,7 @@ describe("ClaudeAdapterLive", () => {
391391 });
392392
393393 const createInput = harness.getLastCreateQueryInput();
394 assert.equal(createInput?.options.effort, "xhigh");
394 assert.equal(createInput?.options.effort, "max");
395395 }).pipe(
396396 Effect.provideService(Random.Random, makeDeterministicRandomService()),
397397 Effect.provide(harness.layer),
apps/server/src/provider/Layers/ClaudeAdapter.ts+9-2
......@@ -84,6 +84,7 @@ type ClaudeToolResultStreamKind = Extract<
8484 RuntimeContentStreamKind,
8585 "command_output" | "file_change_output"
8686>;
87type ClaudeSdkEffort = NonNullable<ClaudeQueryOptions["effort"]>;
8788
8889type PromptQueueItem =
8990 | {
......@@ -217,11 +218,17 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause<Error>): ReadonlyArray
217218
218219function getEffectiveClaudeAgentEffort(
219220 effort: ClaudeAgentEffort | null | undefined,
220): Exclude<ClaudeAgentEffort, "ultrathink"> | null {
221): ClaudeSdkEffort | null {
221222 if (!effort) {
222223 return null;
223224 }
224 return effort === "ultrathink" ? null : effort;
225 if (effort === "ultrathink") {
226 return null;
227 }
228 if (effort === "xhigh") {
229 return "max";
230 }
231 return effort;
225232}
226233
227234function isClaudeInterruptedMessage(message: string): boolean {
apps/web/src/components/ChatView.browser.tsx+18-11
......@@ -17,12 +17,7 @@ import {
1717 OrchestrationSessionStatus,
1818 DEFAULT_SERVER_SETTINGS,
1919} from "@t3tools/contracts";
20import {
21 scopedProjectKey,
22 scopedThreadKey,
23 scopeProjectRef,
24 scopeThreadRef,
25} from "@t3tools/client-runtime";
20import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
2621import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
2722import { HttpResponse, http, ws } from "msw";
2823import { setupWorker } from "msw/browser";
......@@ -52,6 +47,7 @@ import { __resetLocalApiForTests } from "../localApi";
5247import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
5348import { getServerConfig } from "../rpc/serverState";
5449import { getRouter } from "../router";
50import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
5551import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store";
5652import { useTerminalStateStore } from "../terminalStateStore";
5753import { useUiStateStore } from "../uiStateStore";
......@@ -78,7 +74,18 @@ const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID);
7874const THREAD_KEY = scopedThreadKey(THREAD_REF);
7975const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
8076const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`;
81const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJECT_ID));
77const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings(
78 {
79 environmentId: LOCAL_ENVIRONMENT_ID,
80 id: PROJECT_ID,
81 cwd: "/repo/project",
82 repositoryIdentity: null,
83 },
84 {
85 sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode,
86 sidebarProjectGroupingOverrides: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingOverrides,
87 },
88);
8289const NOW_ISO = "2026-03-04T12:00:00.000Z";
8390const BASE_TIME_MS = Date.parse(NOW_ISO);
8491const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'></svg>";
......@@ -1638,12 +1645,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
16381645 customWsRpcResolver = null;
16391646 document.body.innerHTML = "";
16401647 });
1641 it("re-expands the bootstrap project using its scoped key", async () => {
1648 it("re-expands the bootstrap project using its logical key", async () => {
16421649 useUiStateStore.setState({
16431650 projectExpandedById: {
1644 [PROJECT_KEY]: false,
1651 [PROJECT_LOGICAL_KEY]: false,
16451652 },
1646 projectOrder: [PROJECT_KEY],
1653 projectOrder: [PROJECT_LOGICAL_KEY],
16471654 threadLastVisitedAtById: {},
16481655 });
16491656
......@@ -1658,7 +1665,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
16581665 try {
16591666 await vi.waitFor(
16601667 () => {
1661 expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true);
1668 expect(useUiStateStore.getState().projectExpandedById[PROJECT_LOGICAL_KEY]).toBe(true);
16621669 },
16631670 { timeout: 8_000, interval: 16 },
16641671 );
apps/web/src/components/ChatView.tsx+15-4
......@@ -113,7 +113,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov
113113import { useSettings } from "../hooks/useSettings";
114114import { resolveAppModelSelection } from "../modelSelection";
115115import { isTerminalFocused } from "../lib/terminalFocus";
116import { deriveLogicalProjectKey } from "../logicalProject";
116import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
117117import {
118118 useSavedEnvironmentRegistryStore,
119119 useSavedEnvironmentRuntimeStore,
......@@ -847,10 +847,16 @@ export default function ChatView(props: ChatViewProps) {
847847 const primaryEnvironmentId = usePrimaryEnvironmentId();
848848 const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId);
849849 const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId);
850 const projectGroupingSettings = useSettings((settings) => ({
851 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
852 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
853 }));
850854 const logicalProjectEnvironments = useMemo(() => {
851855 if (!activeProject) return [];
852 const logicalKey = deriveLogicalProjectKey(activeProject);
853 const memberProjects = allProjects.filter((p) => deriveLogicalProjectKey(p) === logicalKey);
856 const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings);
857 const memberProjects = allProjects.filter(
858 (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey,
859 );
854860 const seen = new Set<string>();
855861 const envs: Array<{
856862 environmentId: EnvironmentId;
......@@ -886,6 +892,7 @@ export default function ChatView(props: ChatViewProps) {
886892 }, [
887893 activeProject,
888894 allProjects,
895 projectGroupingSettings,
889896 primaryEnvironmentId,
890897 savedEnvironmentRegistry,
891898 savedEnvironmentRuntimeById,
......@@ -915,7 +922,10 @@ export default function ChatView(props: ChatViewProps) {
915922 throw new Error("No active project is available for this pull request.");
916923 }
917924 const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id);
918 const logicalProjectKey = deriveLogicalProjectKey(activeProject);
925 const logicalProjectKey = deriveLogicalProjectKeyFromSettings(
926 activeProject,
927 projectGroupingSettings,
928 );
919929 const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey);
920930 if (storedDraftSession) {
921931 setDraftThreadContext(storedDraftSession.draftId, input);
......@@ -976,6 +986,7 @@ export default function ChatView(props: ChatViewProps) {
976986 getDraftSessionByLogicalProjectKey,
977987 isServerThread,
978988 navigate,
989 projectGroupingSettings,
979990 routeKind,
980991 setDraftThreadContext,
981992 setLogicalProjectDraftThreadId,
apps/web/src/components/Sidebar.tsx+615-192
......@@ -31,10 +31,11 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-
3131import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
3232import { CSS } from "@dnd-kit/utilities";
3333import {
34 type ContextMenuItem,
3435 type DesktopUpdateState,
3536 ProjectId,
36 type ScopedProjectRef,
3737 type ScopedThreadRef,
38 type SidebarProjectGroupingMode,
3839 type ThreadEnvMode,
3940 ThreadId,
4041 type GitStatusResult,
......@@ -59,7 +60,6 @@ import { isMacPlatform, newCommandId } from "../lib/utils";
5960import {
6061 selectProjectByRef,
6162 selectProjectsAcrossEnvironments,
62 selectSidebarThreadsForProjectRef,
6363 selectSidebarThreadsForProjectRefs,
6464 selectSidebarThreadsAcrossEnvironments,
6565 selectThreadByRef,
......@@ -102,7 +102,26 @@ import {
102102} from "./desktopUpdate.logic";
103103import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
104104import { Button } from "./ui/button";
105import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
105import {
106 Dialog,
107 DialogDescription,
108 DialogFooter,
109 DialogHeader,
110 DialogPanel,
111 DialogPopup,
112 DialogTitle,
113} from "./ui/dialog";
114import { Input } from "./ui/input";
115import {
116 Menu,
117 MenuGroup,
118 MenuPopup,
119 MenuRadioGroup,
120 MenuRadioItem,
121 MenuSeparator,
122 MenuTrigger,
123} from "./ui/menu";
124import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
106125import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
107126import {
108127 SidebarContent,
......@@ -142,12 +161,18 @@ import { CommandDialogTrigger } from "./ui/command";
142161import { readEnvironmentApi } from "../environmentApi";
143162import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
144163import { useServerKeybindings } from "../rpc/serverState";
145import { deriveLogicalProjectKey } from "../logicalProject";
164import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey } from "../logicalProject";
146165import {
147166 useSavedEnvironmentRegistryStore,
148167 useSavedEnvironmentRuntimeStore,
149168} from "../environments/runtime";
150import type { Project, SidebarThreadSummary } from "../types";
169import type { SidebarThreadSummary } from "../types";
170import {
171 buildPhysicalToLogicalProjectKeyMap,
172 buildSidebarProjectSnapshots,
173 type SidebarProjectGroupMember,
174 type SidebarProjectSnapshot,
175} from "../sidebarProjectGrouping";
151176const THREAD_PREVIEW_LIMIT = 6;
152177const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
153178 updated_at: "Last user message",
......@@ -163,6 +188,11 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {
163188 easing: "ease-out",
164189} as const;
165190const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>();
191const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
192 repository: "Group by repository",
193 repository_path: "Group by repository path",
194 separate: "Keep separate",
195};
166196
167197function threadJumpLabelMapsEqual(
168198 left: ReadonlyMap<string, string>,
......@@ -182,6 +212,28 @@ function threadJumpLabelMapsEqual(
182212 return true;
183213}
184214
215function formatProjectMemberActionLabel(
216 member: SidebarProjectGroupMember,
217 groupedProjectCount: number,
218): string {
219 if (groupedProjectCount <= 1) {
220 return member.name;
221 }
222
223 return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd;
224}
225
226function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string {
227 switch (mode) {
228 case "repository":
229 return "Projects from the same repository share one sidebar row.";
230 case "repository_path":
231 return "Projects group only when both the repository and repo-relative path match.";
232 case "separate":
233 return "Every project path gets its own sidebar row.";
234 }
235}
236
185237function buildThreadJumpLabelMap(input: {
186238 keybindings: ReturnType<typeof useServerKeybindings>;
187239 platform: string;
......@@ -212,15 +264,6 @@ function buildThreadJumpLabelMap(input: {
212264 return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS;
213265}
214266
215type EnvironmentPresence = "local-only" | "remote-only" | "mixed";
216
217type SidebarProjectSnapshot = Project & {
218 projectKey: string;
219 environmentPresence: EnvironmentPresence;
220 memberProjectRefs: readonly ScopedProjectRef[];
221 /** Labels for remote environments this project lives in. */
222 remoteEnvironmentLabels: readonly string[];
223};
224267interface TerminalStatusIndicator {
225268 label: "Terminal process running";
226269 colorClass: string;
......@@ -996,6 +1039,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
9961039 const defaultThreadEnvMode = useSettings<ThreadEnvMode>(
9971040 (settings) => settings.defaultThreadEnvMode,
9981041 );
1042 const projectGroupingSettings = useSettings((settings) => ({
1043 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
1044 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
1045 }));
1046 const { updateSettings } = useUpdateSettings();
9991047 const router = useRouter();
10001048 const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);
10011049 const toggleProject = useUiStateStore((state) => state.toggleProject);
......@@ -1073,58 +1121,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
10731121 useShallow(
10741122 useMemo(
10751123 () => (state: import("../store").AppState) =>
1076 selectSidebarThreadsForProjectRef(
1077 state,
1078 scopeProjectRef(project.environmentId, project.id),
1079 ),
1080 [project.environmentId, project.id],
1081 ),
1082 ),
1083 );
1084 // For grouped projects that span multiple environments, also fetch
1085 // threads from the other member project refs.
1086 const otherMemberRefs = useMemo(
1087 () =>
1088 project.memberProjectRefs.filter(
1089 (ref) => ref.environmentId !== project.environmentId || ref.projectId !== project.id,
1090 ),
1091 [project.memberProjectRefs, project.environmentId, project.id],
1092 );
1093 const otherMemberThreads = useStore(
1094 useShallow(
1095 useMemo(
1096 () =>
1097 otherMemberRefs.length === 0
1098 ? () => [] as SidebarThreadSummary[]
1099 : (state: import("../store").AppState) =>
1100 selectSidebarThreadsForProjectRefs(state, otherMemberRefs),
1101 [otherMemberRefs],
1124 selectSidebarThreadsForProjectRefs(state, project.memberProjectRefs),
1125 [project.memberProjectRefs],
11021126 ),
11031127 ),
11041128 );
1105 const allSidebarThreads = useMemo(
1106 () =>
1107 otherMemberThreads.length === 0 ? sidebarThreads : [...sidebarThreads, ...otherMemberThreads],
1108 [sidebarThreads, otherMemberThreads],
1109 );
11101129 const sidebarThreadByKey = useMemo(
11111130 () =>
11121131 new Map(
1113 allSidebarThreads.map(
1132 sidebarThreads.map(
11141133 (thread) =>
11151134 [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
11161135 ),
11171136 ),
1118 [allSidebarThreads],
1137 [sidebarThreads],
11191138 );
11201139 // Keep a ref so callbacks can read the latest map without appearing in
11211140 // dependency arrays (avoids invalidating every thread-row memo on each
11221141 // thread-list change).
11231142 const sidebarThreadByKeyRef = useRef(sidebarThreadByKey);
11241143 sidebarThreadByKeyRef.current = sidebarThreadByKey;
1125 // All threads from the representative + other member environments are
1126 // already fetched into allSidebarThreads, so we can use them directly.
1127 const projectThreads = allSidebarThreads;
1144 const projectThreads = sidebarThreads;
11281145 const projectExpanded = useUiStateStore(
11291146 (state) => state.projectExpandedById[project.projectKey] ?? true,
11301147 );
......@@ -1141,9 +1158,43 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
11411158 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
11421159 const [renamingTitle, setRenamingTitle] = useState("");
11431160 const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState<string | null>(null);
1161 const [projectRenameTarget, setProjectRenameTarget] = useState<SidebarProjectGroupMember | null>(
1162 null,
1163 );
1164 const [projectRenameTitle, setProjectRenameTitle] = useState("");
1165 const [projectGroupingTarget, setProjectGroupingTarget] =
1166 useState<SidebarProjectGroupMember | null>(null);
1167 const [projectGroupingSelection, setProjectGroupingSelection] = useState<
1168 SidebarProjectGroupingMode | "inherit"
1169 >("inherit");
11441170 const renamingCommittedRef = useRef(false);
11451171 const renamingInputRef = useRef<HTMLInputElement | null>(null);
11461172 const confirmArchiveButtonRefs = useRef(new Map<string, HTMLButtonElement>());
1173 const memberProjectByScopedKey = useMemo(
1174 () =>
1175 new Map(
1176 project.memberProjects.map((member) => [
1177 scopedProjectKey(scopeProjectRef(member.environmentId, member.id)),
1178 member,
1179 ]),
1180 ),
1181 [project.memberProjects],
1182 );
1183 const memberThreadCountByPhysicalKey = useMemo(() => {
1184 const counts = new Map<string, number>(
1185 project.memberProjects.map((member) => [member.physicalProjectKey, 0] as const),
1186 );
1187 for (const thread of projectThreads) {
1188 const member = memberProjectByScopedKey.get(
1189 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
1190 );
1191 if (!member) {
1192 continue;
1193 }
1194 counts.set(member.physicalProjectKey, (counts.get(member.physicalProjectKey) ?? 0) + 1);
1195 }
1196 return counts;
1197 }, [memberProjectByScopedKey, project.memberProjects, projectThreads]);
11471198
11481199 const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => {
11491200 const lastVisitedAtByThreadKey = new Map(
......@@ -1318,6 +1369,88 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
13181369 [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef],
13191370 );
13201371
1372 const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => {
1373 setProjectRenameTarget(member);
1374 setProjectRenameTitle(member.name);
1375 }, []);
1376
1377 const openProjectGroupingDialog = useCallback(
1378 (member: SidebarProjectGroupMember) => {
1379 const overrideKey = deriveProjectGroupingOverrideKey(member);
1380 setProjectGroupingTarget(member);
1381 setProjectGroupingSelection(
1382 projectGroupingSettings.sidebarProjectGroupingOverrides[overrideKey] ?? "inherit",
1383 );
1384 },
1385 [projectGroupingSettings.sidebarProjectGroupingOverrides],
1386 );
1387
1388 const handleRemoveProject = useCallback(
1389 async (member: SidebarProjectGroupMember) => {
1390 const api = readLocalApi();
1391 if (!api) {
1392 return;
1393 }
1394
1395 if ((memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0) {
1396 toastManager.add({
1397 type: "warning",
1398 title: "Project is not empty",
1399 description: "Delete all threads in this project before removing it.",
1400 });
1401 return;
1402 }
1403
1404 const message = [
1405 `Remove project "${member.name}"?`,
1406 `Path: ${member.cwd}`,
1407 ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []),
1408 "This removes only this project entry.",
1409 ].join("\n");
1410 const confirmed = await api.dialogs.confirm(message);
1411 if (!confirmed) {
1412 return;
1413 }
1414
1415 const memberProjectRef = scopeProjectRef(member.environmentId, member.id);
1416
1417 try {
1418 const projectDraftThread = getDraftThreadByProjectRef(memberProjectRef);
1419 if (projectDraftThread) {
1420 clearComposerDraftForThread(projectDraftThread.draftId);
1421 }
1422 clearProjectDraftThreadId(memberProjectRef);
1423 const projectApi = readEnvironmentApi(member.environmentId);
1424 if (!projectApi) {
1425 throw new Error("Project API unavailable.");
1426 }
1427 await projectApi.orchestration.dispatchCommand({
1428 type: "project.delete",
1429 commandId: newCommandId(),
1430 projectId: member.id,
1431 });
1432 } catch (error) {
1433 const message = error instanceof Error ? error.message : "Unknown error removing project.";
1434 console.error("Failed to remove project", {
1435 projectId: member.id,
1436 environmentId: member.environmentId,
1437 error,
1438 });
1439 toastManager.add({
1440 type: "error",
1441 title: `Failed to remove "${member.name}"`,
1442 description: message,
1443 });
1444 }
1445 },
1446 [
1447 clearComposerDraftForThread,
1448 clearProjectDraftThreadId,
1449 getDraftThreadByProjectRef,
1450 memberThreadCountByPhysicalKey,
1451 ],
1452 );
1453
13211454 const handleProjectButtonContextMenu = useCallback(
13221455 (event: React.MouseEvent<HTMLButtonElement>) => {
13231456 event.preventDefault();
......@@ -1326,73 +1459,103 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
13261459 const api = readLocalApi();
13271460 if (!api) return;
13281461
1462 const actionHandlers = new Map<string, () => Promise<void> | void>();
1463 const makeLeaf = (
1464 action: "rename" | "grouping" | "copy-path" | "delete",
1465 member: SidebarProjectGroupMember,
1466 options?: {
1467 destructive?: boolean;
1468 disabled?: boolean;
1469 },
1470 ): ContextMenuItem<string> => {
1471 const id = `${action}:${member.physicalProjectKey}`;
1472 actionHandlers.set(id, () => {
1473 switch (action) {
1474 case "rename":
1475 openProjectRenameDialog(member);
1476 return;
1477 case "grouping":
1478 openProjectGroupingDialog(member);
1479 return;
1480 case "copy-path":
1481 copyPathToClipboard(member.cwd, { path: member.cwd });
1482 return;
1483 case "delete":
1484 return handleRemoveProject(member);
1485 }
1486 });
1487
1488 return {
1489 id,
1490 label: formatProjectMemberActionLabel(member, project.groupedProjectCount),
1491 ...(options?.destructive ? { destructive: true } : {}),
1492 ...(options?.disabled ? { disabled: true } : {}),
1493 };
1494 };
1495
1496 const buildTargetedItem = (
1497 action: "rename" | "grouping" | "copy-path" | "delete",
1498 label: string,
1499 options?: {
1500 destructive?: boolean;
1501 isDisabled?: (member: SidebarProjectGroupMember) => boolean;
1502 },
1503 ): ContextMenuItem<string> => {
1504 if (project.memberProjects.length === 1) {
1505 const singleMember = project.memberProjects[0]!;
1506 return {
1507 ...makeLeaf(action, singleMember, {
1508 ...(options?.destructive ? { destructive: true } : {}),
1509 ...(options?.isDisabled?.(singleMember) ? { disabled: true } : {}),
1510 }),
1511 label,
1512 };
1513 }
1514
1515 return {
1516 id: `${action}:submenu`,
1517 label,
1518 children: project.memberProjects.map((member) =>
1519 makeLeaf(action, member, {
1520 ...(options?.destructive ? { destructive: true } : {}),
1521 ...(options?.isDisabled?.(member) ? { disabled: true } : {}),
1522 }),
1523 ),
1524 };
1525 };
1526
13291527 const clicked = await api.contextMenu.show(
13301528 [
1331 { id: "copy-path", label: "Copy Project Path" },
1332 { id: "delete", label: "Remove project", destructive: true },
1529 buildTargetedItem("rename", "Rename project"),
1530 buildTargetedItem("grouping", "Project grouping…"),
1531 buildTargetedItem("copy-path", "Copy Project Path"),
1532 buildTargetedItem("delete", "Remove project", {
1533 destructive: true,
1534 isDisabled: (member) =>
1535 (memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0,
1536 }),
13331537 ],
13341538 {
13351539 x: event.clientX,
13361540 y: event.clientY,
13371541 },
13381542 );
1339 if (clicked === "copy-path") {
1340 copyPathToClipboard(project.cwd, { path: project.cwd });
1341 return;
1342 }
1343 if (clicked !== "delete") return;
13441543
1345 if (projectThreads.length > 0) {
1346 toastManager.add({
1347 type: "warning",
1348 title: "Project is not empty",
1349 description: "Delete all threads in this project before removing it.",
1350 });
1544 if (!clicked) {
13511545 return;
13521546 }
13531547
1354 const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`);
1355 if (!confirmed) return;
1356
1357 try {
1358 const projectDraftThread = getDraftThreadByProjectRef(
1359 scopeProjectRef(project.environmentId, project.id),
1360 );
1361 if (projectDraftThread) {
1362 clearComposerDraftForThread(projectDraftThread.draftId);
1363 }
1364 clearProjectDraftThreadId(scopeProjectRef(project.environmentId, project.id));
1365 const projectApi = readEnvironmentApi(project.environmentId);
1366 if (!projectApi) {
1367 throw new Error("Project API unavailable.");
1368 }
1369 await projectApi.orchestration.dispatchCommand({
1370 type: "project.delete",
1371 commandId: newCommandId(),
1372 projectId: project.id,
1373 });
1374 } catch (error) {
1375 const message =
1376 error instanceof Error ? error.message : "Unknown error removing project.";
1377 console.error("Failed to remove project", { projectId: project.id, error });
1378 toastManager.add({
1379 type: "error",
1380 title: `Failed to remove "${project.name}"`,
1381 description: message,
1382 });
1383 }
1548 await actionHandlers.get(clicked)?.();
13841549 })();
13851550 },
13861551 [
1387 clearComposerDraftForThread,
1388 clearProjectDraftThreadId,
13891552 copyPathToClipboard,
1390 getDraftThreadByProjectRef,
1391 project.cwd,
1392 project.environmentId,
1393 project.id,
1394 project.name,
1395 projectThreads.length,
1553 handleRemoveProject,
1554 memberThreadCountByPhysicalKey,
1555 openProjectGroupingDialog,
1556 openProjectRenameDialog,
1557 project.groupedProjectCount,
1558 project.memberProjects,
13961559 suppressProjectClickForContextMenuRef,
13971560 ],
13981561 );
......@@ -1503,10 +1666,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
15031666 ],
15041667 );
15051668
1506 const handleCreateThreadClick = useCallback(
1507 (event: React.MouseEvent<HTMLButtonElement>) => {
1508 event.preventDefault();
1509 event.stopPropagation();
1669 const createThreadForProjectMember = useCallback(
1670 (member: SidebarProjectGroupMember) => {
15101671 const currentRouteParams =
15111672 router.state.matches[router.state.matches.length - 1]?.params ?? {};
15121673 const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams);
......@@ -1522,12 +1683,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
15221683 ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null)
15231684 : null;
15241685 const seedContext = resolveSidebarNewThreadSeedContext({
1525 projectId: project.id,
1686 projectId: member.id,
15261687 defaultEnvMode: resolveSidebarNewThreadEnvMode({
15271688 defaultEnvMode: defaultThreadEnvMode,
15281689 }),
15291690 activeThread:
1530 currentActiveThread && currentActiveThread.projectId === project.id
1691 currentActiveThread && currentActiveThread.projectId === member.id
15311692 ? {
15321693 projectId: currentActiveThread.projectId,
15331694 branch: currentActiveThread.branch,
......@@ -1535,7 +1696,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
15351696 }
15361697 : null,
15371698 activeDraftThread:
1538 currentActiveDraftThread && currentActiveDraftThread.projectId === project.id
1699 currentActiveDraftThread && currentActiveDraftThread.projectId === member.id
15391700 ? {
15401701 projectId: currentActiveDraftThread.projectId,
15411702 branch: currentActiveDraftThread.branch,
......@@ -1544,7 +1705,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
15441705 }
15451706 : null,
15461707 });
1547 void handleNewThread(scopeProjectRef(project.environmentId, project.id), {
1708 void handleNewThread(scopeProjectRef(member.environmentId, member.id), {
15481709 ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}),
15491710 ...(seedContext.worktreePath !== undefined
15501711 ? { worktreePath: seedContext.worktreePath }
......@@ -1552,7 +1713,47 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
15521713 envMode: seedContext.envMode,
15531714 });
15541715 },
1555 [defaultThreadEnvMode, handleNewThread, project.environmentId, project.id, router],
1716 [defaultThreadEnvMode, handleNewThread, router],
1717 );
1718
1719 const handleCreateThreadClick = useCallback(
1720 (event: React.MouseEvent<HTMLButtonElement>) => {
1721 event.preventDefault();
1722 event.stopPropagation();
1723
1724 if (project.memberProjects.length === 1) {
1725 createThreadForProjectMember(project.memberProjects[0]!);
1726 return;
1727 }
1728
1729 void (async () => {
1730 const api = readLocalApi();
1731 if (!api) {
1732 return;
1733 }
1734 const clicked = await api.contextMenu.show(
1735 project.memberProjects.map((member) => ({
1736 id: member.physicalProjectKey,
1737 label: formatProjectMemberActionLabel(member, project.groupedProjectCount),
1738 })),
1739 {
1740 x: event.clientX,
1741 y: event.clientY,
1742 },
1743 );
1744 if (!clicked) {
1745 return;
1746 }
1747 const targetMember = project.memberProjects.find(
1748 (member) => member.physicalProjectKey === clicked,
1749 );
1750 if (!targetMember) {
1751 return;
1752 }
1753 createThreadForProjectMember(targetMember);
1754 })();
1755 },
1756 [createThreadForProjectMember, project.groupedProjectCount, project.memberProjects],
15561757 );
15571758
15581759 const attemptArchiveThread = useCallback(
......@@ -1623,6 +1824,88 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
16231824 [],
16241825 );
16251826
1827 const closeProjectRenameDialog = useCallback(() => {
1828 setProjectRenameTarget(null);
1829 setProjectRenameTitle("");
1830 }, []);
1831
1832 const submitProjectRename = useCallback(async () => {
1833 if (!projectRenameTarget) {
1834 return;
1835 }
1836
1837 const trimmed = projectRenameTitle.trim();
1838 if (trimmed.length === 0) {
1839 toastManager.add({
1840 type: "warning",
1841 title: "Project title cannot be empty",
1842 });
1843 return;
1844 }
1845
1846 if (trimmed === projectRenameTarget.name) {
1847 closeProjectRenameDialog();
1848 return;
1849 }
1850
1851 const api = readEnvironmentApi(projectRenameTarget.environmentId);
1852 if (!api) {
1853 toastManager.add({
1854 type: "error",
1855 title: "Failed to rename project",
1856 description: "Project API unavailable.",
1857 });
1858 return;
1859 }
1860
1861 try {
1862 await api.orchestration.dispatchCommand({
1863 type: "project.meta.update",
1864 commandId: newCommandId(),
1865 projectId: projectRenameTarget.id,
1866 title: trimmed,
1867 });
1868 closeProjectRenameDialog();
1869 } catch (error) {
1870 toastManager.add({
1871 type: "error",
1872 title: "Failed to rename project",
1873 description: error instanceof Error ? error.message : "An error occurred.",
1874 });
1875 }
1876 }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]);
1877
1878 const closeProjectGroupingDialog = useCallback(() => {
1879 setProjectGroupingTarget(null);
1880 setProjectGroupingSelection("inherit");
1881 }, []);
1882
1883 const saveProjectGroupingPreference = useCallback(() => {
1884 if (!projectGroupingTarget) {
1885 return;
1886 }
1887
1888 const overrideKey = deriveProjectGroupingOverrideKey(projectGroupingTarget);
1889 const nextOverrides = {
1890 ...projectGroupingSettings.sidebarProjectGroupingOverrides,
1891 };
1892 if (projectGroupingSelection === "inherit") {
1893 delete nextOverrides[overrideKey];
1894 } else {
1895 nextOverrides[overrideKey] = projectGroupingSelection;
1896 }
1897 updateSettings({
1898 sidebarProjectGroupingOverrides: nextOverrides,
1899 });
1900 closeProjectGroupingDialog();
1901 }, [
1902 closeProjectGroupingDialog,
1903 projectGroupingSelection,
1904 projectGroupingSettings.sidebarProjectGroupingOverrides,
1905 projectGroupingTarget,
1906 updateSettings,
1907 ]);
1908
16261909 const handleThreadContextMenu = useCallback(
16271910 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
16281911 const api = readLocalApi();
......@@ -1630,7 +1913,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
16301913 const threadKey = scopedThreadKey(threadRef);
16311914 const thread = sidebarThreadByKeyRef.current.get(threadKey) ?? null;
16321915 if (!thread) return;
1633 const threadWorkspacePath = thread.worktreePath ?? project.cwd ?? null;
1916 const threadProject = memberProjectByScopedKey.get(
1917 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
1918 );
1919 const threadWorkspacePath = thread.worktreePath ?? threadProject?.cwd ?? project.cwd ?? null;
16341920 const clicked = await api.contextMenu.show(
16351921 [
16361922 { id: "rename", label: "Rename thread" },
......@@ -1689,6 +1975,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
16891975 copyThreadIdToClipboard,
16901976 deleteThread,
16911977 markThreadUnread,
1978 memberProjectByScopedKey,
16921979 project.cwd,
16931980 ],
16941981 );
......@@ -1732,8 +2019,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
17322019 />
17332020 )}
17342021 <ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} />
1735 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1736 {project.name}
2022 <span className="flex min-w-0 flex-1 items-center gap-2">
2023 <span className="truncate text-xs font-medium text-foreground/90">
2024 {project.displayName}
2025 </span>
2026 {project.groupedProjectCount > 1 ? (
2027 <span className="shrink-0 text-[10px] text-muted-foreground/60">
2028 {project.groupedProjectCount} projects
2029 </span>
2030 ) : null}
17372031 </span>
17382032 </SidebarMenuButton>
17392033 {/* Environment badge – visible by default, crossfades with the
......@@ -1766,7 +2060,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
17662060 <div className="pointer-events-none absolute top-1 right-1.5 opacity-0 transition-opacity duration-150 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100">
17672061 <button
17682062 type="button"
1769 aria-label={`Create new thread in ${project.name}`}
2063 aria-label={`Create new thread in ${project.displayName}`}
17702064 data-testid="new-thread-button"
17712065 className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/70 hover:bg-secondary hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
17722066 onClick={handleCreateThreadClick}
......@@ -1817,6 +2111,124 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
18172111 expandThreadListForProject={expandThreadListForProject}
18182112 collapseThreadListForProject={collapseThreadListForProject}
18192113 />
2114
2115 <Dialog
2116 open={projectRenameTarget !== null}
2117 onOpenChange={(open) => {
2118 if (!open) {
2119 closeProjectRenameDialog();
2120 }
2121 }}
2122 >
2123 <DialogPopup className="max-w-lg">
2124 <DialogHeader>
2125 <DialogTitle>Rename project</DialogTitle>
2126 <DialogDescription>
2127 {projectRenameTarget
2128 ? `Update the title for ${projectRenameTarget.cwd}.`
2129 : "Update the project title."}
2130 </DialogDescription>
2131 </DialogHeader>
2132 <DialogPanel className="space-y-4">
2133 <div className="grid gap-1.5">
2134 <span className="text-xs font-medium text-foreground">Project title</span>
2135 <Input
2136 aria-label="Project title"
2137 value={projectRenameTitle}
2138 onChange={(event) => setProjectRenameTitle(event.target.value)}
2139 onKeyDown={(event) => {
2140 if (event.key === "Enter") {
2141 event.preventDefault();
2142 void submitProjectRename();
2143 }
2144 }}
2145 />
2146 </div>
2147 {projectRenameTarget?.environmentLabel ? (
2148 <p className="text-xs text-muted-foreground">
2149 Environment: {projectRenameTarget.environmentLabel}
2150 </p>
2151 ) : null}
2152 </DialogPanel>
2153 <DialogFooter>
2154 <Button variant="outline" onClick={closeProjectRenameDialog}>
2155 Cancel
2156 </Button>
2157 <Button onClick={() => void submitProjectRename()}>Save</Button>
2158 </DialogFooter>
2159 </DialogPopup>
2160 </Dialog>
2161
2162 <Dialog
2163 open={projectGroupingTarget !== null}
2164 onOpenChange={(open) => {
2165 if (!open) {
2166 closeProjectGroupingDialog();
2167 }
2168 }}
2169 >
2170 <DialogPopup className="max-w-lg">
2171 <DialogHeader>
2172 <DialogTitle>Project grouping</DialogTitle>
2173 <DialogDescription>
2174 {projectGroupingTarget
2175 ? `Choose how ${projectGroupingTarget.cwd} should be grouped in the sidebar.`
2176 : "Choose how this project should be grouped in the sidebar."}
2177 </DialogDescription>
2178 </DialogHeader>
2179 <DialogPanel className="space-y-4">
2180 <div className="grid gap-1.5">
2181 <span className="text-xs font-medium text-foreground">Grouping rule</span>
2182 <Select
2183 value={projectGroupingSelection}
2184 onValueChange={(value) => {
2185 if (
2186 value === "inherit" ||
2187 value === "repository" ||
2188 value === "repository_path" ||
2189 value === "separate"
2190 ) {
2191 setProjectGroupingSelection(value);
2192 }
2193 }}
2194 >
2195 <SelectTrigger className="w-full" aria-label="Project grouping rule">
2196 <SelectValue>
2197 {projectGroupingSelection === "inherit"
2198 ? `Use global default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})`
2199 : PROJECT_GROUPING_MODE_LABELS[projectGroupingSelection]}
2200 </SelectValue>
2201 </SelectTrigger>
2202 <SelectPopup align="end" alignItemWithTrigger={false}>
2203 <SelectItem hideIndicator value="inherit">
2204 Use global default
2205 </SelectItem>
2206 <SelectItem hideIndicator value="repository">
2207 {PROJECT_GROUPING_MODE_LABELS.repository}
2208 </SelectItem>
2209 <SelectItem hideIndicator value="repository_path">
2210 {PROJECT_GROUPING_MODE_LABELS.repository_path}
2211 </SelectItem>
2212 <SelectItem hideIndicator value="separate">
2213 {PROJECT_GROUPING_MODE_LABELS.separate}
2214 </SelectItem>
2215 </SelectPopup>
2216 </Select>
2217 </div>
2218 <p className="text-xs text-muted-foreground">
2219 {projectGroupingSelection === "inherit"
2220 ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode)
2221 : projectGroupingModeDescription(projectGroupingSelection)}
2222 </p>
2223 </DialogPanel>
2224 <DialogFooter>
2225 <Button variant="outline" onClick={closeProjectGroupingDialog}>
2226 Cancel
2227 </Button>
2228 <Button onClick={saveProjectGroupingPreference}>Save</Button>
2229 </DialogFooter>
2230 </DialogPopup>
2231 </Dialog>
18202232 </>
18212233 );
18222234});
......@@ -1853,13 +2265,17 @@ type SortableProjectHandleProps = Pick<
18532265function ProjectSortMenu({
18542266 projectSortOrder,
18552267 threadSortOrder,
2268 projectGroupingMode,
18562269 onProjectSortOrderChange,
18572270 onThreadSortOrderChange,
2271 onProjectGroupingModeChange,
18582272}: {
18592273 projectSortOrder: SidebarProjectSortOrder;
18602274 threadSortOrder: SidebarThreadSortOrder;
2275 projectGroupingMode: SidebarProjectGroupingMode;
18612276 onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void;
18622277 onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
2278 onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;
18632279}) {
18642280 return (
18652281 <Menu>
......@@ -1912,6 +2328,30 @@ function ProjectSortMenu({
19122328 ))}
19132329 </MenuRadioGroup>
19142330 </MenuGroup>
2331 <MenuSeparator />
2332 <MenuGroup>
2333 <div className="px-2 pt-2 pb-1 font-medium text-muted-foreground sm:text-xs">
2334 Group projects
2335 </div>
2336 <MenuRadioGroup
2337 value={projectGroupingMode}
2338 onValueChange={(value) => {
2339 if (value === "repository" || value === "repository_path" || value === "separate") {
2340 onProjectGroupingModeChange(value);
2341 }
2342 }}
2343 >
2344 {(
2345 Object.entries(PROJECT_GROUPING_MODE_LABELS) as Array<
2346 [SidebarProjectGroupingMode, string]
2347 >
2348 ).map(([value, label]) => (
2349 <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs">
2350 {label}
2351 </MenuRadioItem>
2352 ))}
2353 </MenuRadioGroup>
2354 </MenuGroup>
19152355 </MenuPopup>
19162356 </Menu>
19172357 );
......@@ -2029,6 +2469,7 @@ interface SidebarProjectsContentProps {
20292469 handleDesktopUpdateButtonClick: () => void;
20302470 projectSortOrder: SidebarProjectSortOrder;
20312471 threadSortOrder: SidebarThreadSortOrder;
2472 projectGroupingMode: SidebarProjectGroupingMode;
20322473 updateSettings: ReturnType<typeof useUpdateSettings>["updateSettings"];
20332474 openAddProject: () => void;
20342475 isManualProjectSorting: boolean;
......@@ -2068,6 +2509,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
20682509 handleDesktopUpdateButtonClick,
20692510 projectSortOrder,
20702511 threadSortOrder,
2512 projectGroupingMode,
20712513 updateSettings,
20722514 openAddProject,
20732515 isManualProjectSorting,
......@@ -2108,6 +2550,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
21082550 },
21092551 [updateSettings],
21102552 );
2553 const handleProjectGroupingModeChange = useCallback(
2554 (groupingMode: SidebarProjectGroupingMode) => {
2555 updateSettings({ sidebarProjectGroupingMode: groupingMode });
2556 },
2557 [updateSettings],
2558 );
21112559
21122560 return (
21132561 <SidebarContent className="gap-0">
......@@ -2166,8 +2614,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
21662614 <ProjectSortMenu
21672615 projectSortOrder={projectSortOrder}
21682616 threadSortOrder={threadSortOrder}
2617 projectGroupingMode={projectGroupingMode}
21692618 onProjectSortOrderChange={handleProjectSortOrderChange}
21702619 onThreadSortOrderChange={handleThreadSortOrderChange}
2620 onProjectGroupingModeChange={handleProjectGroupingModeChange}
21712621 />
21722622 <Tooltip>
21732623 <TooltipTrigger
......@@ -2282,6 +2732,11 @@ export default function Sidebar() {
22822732 const isOnSettings = pathname.startsWith("/settings");
22832733 const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder);
22842734 const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder);
2735 const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode);
2736 const projectGroupingSettings = useSettings((settings) => ({
2737 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
2738 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
2739 }));
22852740 const { updateSettings } = useUpdateSettings();
22862741 const { handleNewThread } = useNewThreadHandler();
22872742 const { archiveThread, deleteThread } = useThreadActions();
......@@ -2319,79 +2774,36 @@ export default function Sidebar() {
23192774 // cross-environment grouping. Projects that share a repositoryIdentity
23202775 // canonicalKey are treated as one logical project in the sidebar.
23212776 const physicalToLogicalKey = useMemo(() => {
2322 const mapping = new Map<string, string>();
2323 for (const project of orderedProjects) {
2324 const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id));
2325 mapping.set(physicalKey, deriveLogicalProjectKey(project));
2326 }
2327 return mapping;
2328 }, [orderedProjects]);
2777 return buildPhysicalToLogicalProjectKeyMap({
2778 projects: orderedProjects,
2779 settings: projectGroupingSettings,
2780 });
2781 }, [orderedProjects, projectGroupingSettings]);
2782 const projectPhysicalKeyByScopedRef = useMemo(
2783 () =>
2784 new Map(
2785 orderedProjects.map((project) => [
2786 scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
2787 derivePhysicalProjectKey(project),
2788 ]),
2789 ),
2790 [orderedProjects],
2791 );
23292792
23302793 const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(() => {
2331 // Group projects by logical key while preserving insertion order from
2332 // orderedProjects.
2333 const groupedMembers = new Map<string, Project[]>();
2334 for (const project of orderedProjects) {
2335 const logicalKey = deriveLogicalProjectKey(project);
2336 const existing = groupedMembers.get(logicalKey);
2337 if (existing) {
2338 existing.push(project);
2339 } else {
2340 groupedMembers.set(logicalKey, [project]);
2341 }
2342 }
2343
2344 const result: SidebarProjectSnapshot[] = [];
2345 const seen = new Set<string>();
2346 for (const project of orderedProjects) {
2347 const logicalKey = deriveLogicalProjectKey(project);
2348 if (seen.has(logicalKey)) continue;
2349 seen.add(logicalKey);
2350
2351 const members = groupedMembers.get(logicalKey)!;
2352 // Prefer the primary environment's project as the representative.
2353 const representative: Project | undefined =
2354 (primaryEnvironmentId
2355 ? members.find((p) => p.environmentId === primaryEnvironmentId)
2356 : undefined) ?? members[0];
2357 if (!representative) continue;
2358 const hasLocal =
2359 primaryEnvironmentId !== null &&
2360 members.some((p) => p.environmentId === primaryEnvironmentId);
2361 const hasRemote =
2362 primaryEnvironmentId !== null
2363 ? members.some((p) => p.environmentId !== primaryEnvironmentId)
2364 : false;
2365
2366 const refs = members.map((p) => scopeProjectRef(p.environmentId, p.id));
2367 const remoteLabels = members
2368 .filter((p) => primaryEnvironmentId !== null && p.environmentId !== primaryEnvironmentId)
2369 .map((p) => {
2370 const rt = savedEnvironmentRuntimeById[p.environmentId];
2371 const saved = savedEnvironmentRegistry[p.environmentId];
2372 return rt?.descriptor?.label ?? saved?.label ?? p.environmentId;
2373 });
2374 const snapshot: SidebarProjectSnapshot = {
2375 id: representative.id,
2376 environmentId: representative.environmentId,
2377 name: representative.name,
2378 cwd: representative.cwd,
2379 repositoryIdentity: representative.repositoryIdentity ?? null,
2380 defaultModelSelection: representative.defaultModelSelection,
2381 createdAt: representative.createdAt,
2382 updatedAt: representative.updatedAt,
2383 scripts: representative.scripts,
2384 projectKey: logicalKey,
2385 environmentPresence:
2386 hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only",
2387 memberProjectRefs: refs,
2388 remoteEnvironmentLabels: remoteLabels,
2389 };
2390 result.push(snapshot);
2391 }
2392 return result;
2794 return buildSidebarProjectSnapshots({
2795 projects: orderedProjects,
2796 settings: projectGroupingSettings,
2797 primaryEnvironmentId,
2798 resolveEnvironmentLabel: (environmentId) => {
2799 const rt = savedEnvironmentRuntimeById[environmentId];
2800 const saved = savedEnvironmentRegistry[environmentId];
2801 return rt?.descriptor?.label ?? saved?.label ?? null;
2802 },
2803 });
23932804 }, [
23942805 orderedProjects,
2806 projectGroupingSettings,
23952807 primaryEnvironmentId,
23962808 savedEnvironmentRegistry,
23972809 savedEnvironmentRuntimeById,
......@@ -2419,18 +2831,22 @@ export default function Sidebar() {
24192831 }
24202832 const activeThread = sidebarThreadByKey.get(routeThreadKey);
24212833 if (!activeThread) return null;
2422 const physicalKey = scopedProjectKey(
2423 scopeProjectRef(activeThread.environmentId, activeThread.projectId),
2424 );
2834 const physicalKey =
2835 projectPhysicalKeyByScopedRef.get(
2836 scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)),
2837 ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId));
24252838 return physicalToLogicalKey.get(physicalKey) ?? physicalKey;
2426 }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]);
2839 }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]);
24272840
24282841 // Group threads by logical project key so all threads from grouped projects
24292842 // are displayed together.
24302843 const threadsByProjectKey = useMemo(() => {
24312844 const next = new Map<string, SidebarThreadSummary[]>();
24322845 for (const thread of sidebarThreads) {
2433 const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
2846 const physicalKey =
2847 projectPhysicalKeyByScopedRef.get(
2848 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
2849 ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
24342850 const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey;
24352851 const existing = next.get(logicalKey);
24362852 if (existing) {
......@@ -2440,7 +2856,7 @@ export default function Sidebar() {
24402856 }
24412857 }
24422858 return next;
2443 }, [sidebarThreads, physicalToLogicalKey]);
2859 }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]);
24442860 const getCurrentSidebarShortcutContext = useCallback(
24452861 () => ({
24462862 terminalFocus: isTerminalFocused(),
......@@ -2507,8 +2923,10 @@ export default function Sidebar() {
25072923 const activeProject = sidebarProjects.find((project) => project.projectKey === active.id);
25082924 const overProject = sidebarProjects.find((project) => project.projectKey === over.id);
25092925 if (!activeProject || !overProject) return;
2510 const activeMemberKeys = activeProject.memberProjectRefs.map(scopedProjectKey);
2511 const overMemberKeys = overProject.memberProjectRefs.map(scopedProjectKey);
2926 const activeMemberKeys = activeProject.memberProjects.map(
2927 (member) => member.physicalProjectKey,
2928 );
2929 const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey);
25122930 reorderProjects(activeMemberKeys, overMemberKeys);
25132931 },
25142932 [sidebarProjectSortOrder, reorderProjects, sidebarProjects],
......@@ -2557,7 +2975,10 @@ export default function Sidebar() {
25572975 id: project.projectKey,
25582976 }));
25592977 const sortableThreads = visibleThreads.map((thread) => {
2560 const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
2978 const physicalKey =
2979 projectPhysicalKeyByScopedRef.get(
2980 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
2981 ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
25612982 return {
25622983 ...thread,
25632984 projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId,
......@@ -2574,6 +2995,7 @@ export default function Sidebar() {
25742995 }, [
25752996 sidebarProjectSortOrder,
25762997 physicalToLogicalKey,
2998 projectPhysicalKeyByScopedRef,
25772999 sidebarProjectByKey,
25783000 sidebarProjects,
25793001 visibleThreads,
......@@ -2978,6 +3400,7 @@ export default function Sidebar() {
29783400 handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick}
29793401 projectSortOrder={sidebarProjectSortOrder}
29803402 threadSortOrder={sidebarThreadSortOrder}
3403 projectGroupingMode={sidebarProjectGroupingMode}
29813404 updateSettings={updateSettings}
29823405 openAddProject={openAddProjectCommandPalette}
29833406 isManualProjectSorting={isManualProjectSorting}
apps/web/src/contextMenuFallback.test.ts created+221
......@@ -0,0 +1,221 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3import { showContextMenuFallback } from "./contextMenuFallback";
4
5type FakeListener = (event: FakeDomEvent) => void;
6
7class FakeDomEvent {
8 defaultPrevented = false;
9
10 constructor(
11 readonly type: string,
12 init: Record<string, unknown> = {},
13 ) {
14 Object.assign(this, init);
15 }
16
17 preventDefault() {
18 this.defaultPrevented = true;
19 }
20}
21
22class FakeElement {
23 children: FakeElement[] = [];
24 parent: FakeElement | null = null;
25 style: Record<string, string> & { cssText?: string } = {};
26 dataset: Record<string, string> = {};
27 className = "";
28 disabled = false;
29 type = "";
30 private textValue = "";
31 private readonly listeners = new Map<string, FakeListener[]>();
32
33 constructor(readonly tagName: string) {}
34
35 appendChild(child: FakeElement) {
36 child.parent = this;
37 this.children.push(child);
38 return child;
39 }
40
41 remove() {
42 if (!this.parent) {
43 return;
44 }
45 const index = this.parent.children.indexOf(this);
46 if (index >= 0) {
47 this.parent.children.splice(index, 1);
48 }
49 this.parent = null;
50 }
51
52 addEventListener(type: string, listener: FakeListener) {
53 const existing = this.listeners.get(type) ?? [];
54 existing.push(listener);
55 this.listeners.set(type, existing);
56 }
57
58 dispatchEvent(event: FakeDomEvent) {
59 for (const listener of this.listeners.get(event.type) ?? []) {
60 listener(event);
61 }
62 return true;
63 }
64
65 set textContent(value: string) {
66 this.textValue = value;
67 }
68
69 get textContent() {
70 return `${this.textValue}${this.children.map((child) => child.textContent).join("")}`;
71 }
72
73 querySelectorAll(tagName: string): FakeElement[] {
74 const matches: FakeElement[] = [];
75 if (this.tagName === tagName) {
76 matches.push(this);
77 }
78 for (const child of this.children) {
79 matches.push(...child.querySelectorAll(tagName));
80 }
81 return matches;
82 }
83
84 getBoundingClientRect() {
85 const left = Number.parseInt(this.style.left ?? "0", 10) || 0;
86 const top = Number.parseInt(this.style.top ?? "0", 10) || 0;
87 const width = this.tagName === "div" ? 180 : 140;
88 const height = this.tagName === "div" ? 120 : 28;
89 return {
90 left,
91 top,
92 width,
93 height,
94 right: left + width,
95 bottom: top + height,
96 };
97 }
98}
99
100class FakeBody extends FakeElement {
101 private html = "";
102
103 constructor() {
104 super("body");
105 }
106
107 set innerHTML(value: string) {
108 this.html = value;
109 this.children = [];
110 }
111
112 get innerHTML() {
113 return this.html;
114 }
115}
116
117class FakeDocument {
118 body = new FakeBody();
119 private readonly listeners = new Map<string, FakeListener[]>();
120
121 createElement(tagName: string) {
122 return new FakeElement(tagName);
123 }
124
125 addEventListener(type: string, listener: FakeListener) {
126 const existing = this.listeners.get(type) ?? [];
127 existing.push(listener);
128 this.listeners.set(type, existing);
129 }
130
131 removeEventListener(type: string, listener: FakeListener) {
132 const existing = this.listeners.get(type);
133 if (!existing) {
134 return;
135 }
136 const index = existing.indexOf(listener);
137 if (index >= 0) {
138 existing.splice(index, 1);
139 }
140 }
141
142 querySelectorAll(tagName: string) {
143 return this.body.querySelectorAll(tagName);
144 }
145}
146
147function findButton(label: string): FakeElement | undefined {
148 return (document as unknown as FakeDocument)
149 .querySelectorAll("button")
150 .find((button) => button.textContent.includes(label));
151}
152
153beforeEach(() => {
154 vi.stubGlobal("document", new FakeDocument());
155 vi.stubGlobal("window", {
156 innerWidth: 1280,
157 innerHeight: 800,
158 });
159 vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => {
160 callback(0);
161 return 0;
162 });
163 vi.stubGlobal(
164 "MouseEvent",
165 class extends FakeDomEvent {
166 constructor(type: string, init: Record<string, unknown> = {}) {
167 super(type, init);
168 }
169 },
170 );
171 vi.stubGlobal(
172 "KeyboardEvent",
173 class extends FakeDomEvent {
174 constructor(type: string, init: Record<string, unknown> = {}) {
175 super(type, init);
176 }
177 },
178 );
179});
180
181afterEach(() => {
182 vi.unstubAllGlobals();
183});
184
185describe("showContextMenuFallback", () => {
186 it("resolves a clicked flat menu item", async () => {
187 const selectionPromise = showContextMenuFallback([
188 { id: "rename", label: "Rename" },
189 { id: "delete", label: "Delete", destructive: true },
190 ]);
191
192 const renameButton = findButton("Rename");
193 expect(renameButton).toBeTruthy();
194 renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
195
196 await expect(selectionPromise).resolves.toBe("rename");
197 });
198
199 it("opens nested submenus and resolves the clicked leaf id", async () => {
200 const selectionPromise = showContextMenuFallback([
201 {
202 id: "rename:submenu",
203 label: "Rename project",
204 children: [
205 { id: "rename:project-a", label: "/tmp/project-a" },
206 { id: "rename:project-b", label: "/tmp/project-b" },
207 ],
208 },
209 ]);
210
211 const parentButton = findButton("Rename project");
212 expect(parentButton).toBeTruthy();
213 parentButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
214
215 const childButton = findButton("/tmp/project-b");
216 expect(childButton).toBeTruthy();
217 childButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
218
219 await expect(selectionPromise).resolves.toBe("rename:project-b");
220 });
221});
apps/web/src/contextMenuFallback.ts+115-47
......@@ -1,9 +1,22 @@
11import type { ContextMenuItem } from "@t3tools/contracts";
22
3function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferredTop: number) {
4 const rect = menu.getBoundingClientRect();
5 const left = Math.min(
6 Math.max(4, preferredLeft),
7 Math.max(4, window.innerWidth - rect.width - 4),
8 );
9 const top = Math.min(
10 Math.max(4, preferredTop),
11 Math.max(4, window.innerHeight - rect.height - 4),
12 );
13 menu.style.left = `${left}px`;
14 menu.style.top = `${top}px`;
15}
16
317/**
418 * Imperative DOM-based context menu for non-Electron environments.
5 * Shows a positioned dropdown and returns a promise that resolves
6 * with the clicked item id, or null if dismissed.
19 * Supports nested submenus and resolves with the clicked leaf item id.
720 */
821export function showContextMenuFallback<T extends string>(
922 items: readonly ContextMenuItem<T>[],
......@@ -13,62 +26,117 @@ export function showContextMenuFallback<T extends string>(
1326 const overlay = document.createElement("div");
1427 overlay.style.cssText = "position:fixed;inset:0;z-index:9999";
1528
16 const menu = document.createElement("div");
17 menu.className =
18 "fixed z-[10000] min-w-[140px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95";
19
20 const x = position?.x ?? 0;
21 const y = position?.y ?? 0;
22 menu.style.top = `${y}px`;
23 menu.style.left = `${x}px`;
29 const menuStack: HTMLDivElement[] = [];
2430
25 function cleanup(result: T | null) {
31 const cleanup = (result: T | null) => {
2632 document.removeEventListener("keydown", onKeyDown);
2733 overlay.remove();
28 menu.remove();
34 for (const menu of menuStack) {
35 menu.remove();
36 }
2937 resolve(result);
30 }
38 };
3139
32 function onKeyDown(e: KeyboardEvent) {
33 if (e.key === "Escape") {
34 e.preventDefault();
40 const onKeyDown = (event: KeyboardEvent) => {
41 if (event.key === "Escape") {
42 event.preventDefault();
3543 cleanup(null);
3644 }
37 }
45 };
3846
39 overlay.addEventListener("mousedown", () => cleanup(null));
40 document.addEventListener("keydown", onKeyDown);
41
42 for (const item of items) {
43 const btn = document.createElement("button");
44 btn.type = "button";
45 btn.textContent = item.label;
46 const isDestructiveAction = item.destructive === true || item.id === "delete";
47 const isDisabled = item.disabled === true;
48 btn.disabled = isDisabled;
49 btn.className = isDisabled
50 ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed"
51 : isDestructiveAction
52 ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default"
53 : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default";
54 if (!isDisabled) {
55 btn.addEventListener("click", () => cleanup(item.id));
47 const closeMenusFromLevel = (level: number) => {
48 while (menuStack.length > level) {
49 menuStack.pop()?.remove();
5650 }
57 menu.appendChild(btn);
58 }
51 };
5952
60 document.body.appendChild(overlay);
61 document.body.appendChild(menu);
53 const openMenu = (
54 entries: readonly ContextMenuItem<T>[],
55 preferredLeft: number,
56 preferredTop: number,
57 level: number,
58 ) => {
59 closeMenusFromLevel(level);
6260
63 // Adjust if menu overflows viewport
64 requestAnimationFrame(() => {
65 const rect = menu.getBoundingClientRect();
66 if (rect.right > window.innerWidth) {
67 menu.style.left = `${window.innerWidth - rect.width - 4}px`;
68 }
69 if (rect.bottom > window.innerHeight) {
70 menu.style.top = `${window.innerHeight - rect.height - 4}px`;
61 const menu = document.createElement("div");
62 menu.className =
63 "fixed z-[10000] min-w-[160px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95";
64 menu.style.left = `${preferredLeft}px`;
65 menu.style.top = `${preferredTop}px`;
66 menu.dataset.level = String(level);
67
68 for (const item of entries) {
69 const button = document.createElement("button");
70 button.type = "button";
71 const hasChildren = Array.isArray(item.children) && item.children.length > 0;
72 const isLeafDestructive =
73 !hasChildren && (item.destructive === true || item.id === ("delete" as T));
74 const isDisabled = item.disabled === true;
75 button.disabled = isDisabled;
76 button.className = isDisabled
77 ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed"
78 : isLeafDestructive
79 ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default"
80 : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default";
81
82 const label = document.createElement("span");
83 label.className = "min-w-0 flex-1 truncate";
84 label.textContent = item.label;
85 button.appendChild(label);
86
87 if (hasChildren) {
88 const chevron = document.createElement("span");
89 chevron.className = "shrink-0 text-muted-foreground/70";
90 chevron.textContent = "›";
91 button.appendChild(chevron);
92 }
93
94 if (!isDisabled) {
95 if (hasChildren) {
96 button.addEventListener("mouseenter", () => {
97 const rect = button.getBoundingClientRect();
98 const nextLeft = rect.right + 4;
99 const nextTop = rect.top;
100 openMenu(item.children!, nextLeft, nextTop, level + 1);
101
102 const childMenu = menuStack[level + 1];
103 if (!childMenu) {
104 return;
105 }
106 const childRect = childMenu.getBoundingClientRect();
107 if (childRect.right > window.innerWidth) {
108 clampMenuPosition(childMenu, rect.left - childRect.width - 4, rect.top);
109 }
110 });
111 button.addEventListener("click", (event) => {
112 event.preventDefault();
113 });
114 } else {
115 button.addEventListener("mouseenter", () => {
116 closeMenusFromLevel(level + 1);
117 });
118 button.addEventListener("click", () => cleanup(item.id));
119 }
120 }
121
122 menu.appendChild(button);
71123 }
72 });
124
125 menu.addEventListener("mouseenter", () => {
126 closeMenusFromLevel(level + 1);
127 });
128
129 document.body.appendChild(menu);
130 menuStack[level] = menu;
131
132 requestAnimationFrame(() => {
133 clampMenuPosition(menu, preferredLeft, preferredTop);
134 });
135 };
136
137 overlay.addEventListener("mousedown", () => cleanup(null));
138 document.addEventListener("keydown", onKeyDown);
139 document.body.appendChild(overlay);
140 openMenu(items, position?.x ?? 0, position?.y ?? 0, 0);
73141 });
74142}
apps/web/src/environmentGrouping.test.ts+165-4
......@@ -10,7 +10,12 @@ import {
1010 type AppState,
1111 type EnvironmentState,
1212} from "./store";
13import { deriveLogicalProjectKey } from "./logicalProject";
13import {
14 deriveLogicalProjectKey,
15 deriveLogicalProjectKeyFromSettings,
16 derivePhysicalProjectKey,
17 resolveProjectGroupingMode,
18} from "./logicalProject";
1419import type { Project, SidebarThreadSummary } from "./types";
1520import { DEFAULT_INTERACTION_MODE } from "./types";
1621
......@@ -31,6 +36,10 @@ const threadL1 = ThreadId.make("thread-local-only-1");
3136const threadRO1 = ThreadId.make("thread-remote-only-1");
3237
3338const SHARED_REPO_CANONICAL_KEY = "github.com/example/shared-repo";
39const DEFAULT_GROUPING_SETTINGS = {
40 sidebarProjectGroupingMode: "repository" as const,
41 sidebarProjectGroupingOverrides: {},
42};
3443
3544// ── Factory Helpers ──────────────────────────────────────────────────
3645
......@@ -238,9 +247,7 @@ describe("environment grouping", () => {
238247 environmentId: primaryEnvId,
239248 name: "local-only",
240249 });
241 const key = deriveLogicalProjectKey(project);
242 expect(key).toContain(primaryEnvId);
243 expect(key).toContain(localOnlyProjectId);
250 expect(deriveLogicalProjectKey(project)).toBe(derivePhysicalProjectKey(project));
244251 });
245252
246253 it("groups projects from different environments that share the same canonical key", () => {
......@@ -273,6 +280,134 @@ describe("environment grouping", () => {
273280 expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote));
274281 });
275282
283 it("groups repo root and nested projects from the same repository by default", () => {
284 const rootProject = makeProject({
285 id: sharedProjectPrimaryId,
286 environmentId: primaryEnvId,
287 name: "shared-repo",
288 cwd: "/workspace/repo",
289 repositoryIdentity: {
290 canonicalKey: SHARED_REPO_CANONICAL_KEY,
291 rootPath: "/workspace/repo",
292 locator: {
293 source: "git-remote",
294 remoteName: "origin",
295 remoteUrl: "https://github.com/example/shared-repo.git",
296 },
297 },
298 });
299 const nestedProject = makeProject({
300 id: localOnlyProjectId,
301 environmentId: primaryEnvId,
302 name: "web",
303 cwd: "/workspace/repo/apps/web",
304 repositoryIdentity: {
305 canonicalKey: SHARED_REPO_CANONICAL_KEY,
306 rootPath: "/workspace/repo",
307 locator: {
308 source: "git-remote",
309 remoteName: "origin",
310 remoteUrl: "https://github.com/example/shared-repo.git",
311 },
312 },
313 });
314
315 expect(deriveLogicalProjectKey(rootProject)).toBe(SHARED_REPO_CANONICAL_KEY);
316 expect(deriveLogicalProjectKey(nestedProject)).toBe(SHARED_REPO_CANONICAL_KEY);
317 });
318
319 it("uses repository path grouping when requested", () => {
320 const rootProject = makeProject({
321 id: sharedProjectPrimaryId,
322 environmentId: primaryEnvId,
323 name: "shared-repo",
324 cwd: "/workspace/repo",
325 repositoryIdentity: {
326 canonicalKey: SHARED_REPO_CANONICAL_KEY,
327 rootPath: "/workspace/repo",
328 locator: {
329 source: "git-remote",
330 remoteName: "origin",
331 remoteUrl: "https://github.com/example/shared-repo.git",
332 },
333 },
334 });
335 const nestedProject = makeProject({
336 id: localOnlyProjectId,
337 environmentId: primaryEnvId,
338 name: "web",
339 cwd: "/workspace/repo/apps/web",
340 repositoryIdentity: {
341 canonicalKey: SHARED_REPO_CANONICAL_KEY,
342 rootPath: "/workspace/repo",
343 locator: {
344 source: "git-remote",
345 remoteName: "origin",
346 remoteUrl: "https://github.com/example/shared-repo.git",
347 },
348 },
349 });
350
351 expect(
352 deriveLogicalProjectKey(rootProject, {
353 groupingMode: "repository_path",
354 }),
355 ).toBe(SHARED_REPO_CANONICAL_KEY);
356 expect(
357 deriveLogicalProjectKey(nestedProject, {
358 groupingMode: "repository_path",
359 }),
360 ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`);
361 });
362
363 it("groups matching nested project paths across environments when repo roots differ", () => {
364 const primary = makeProject({
365 id: sharedProjectPrimaryId,
366 environmentId: primaryEnvId,
367 name: "web",
368 cwd: "/workspace/repo/apps/web",
369 repositoryIdentity: {
370 canonicalKey: SHARED_REPO_CANONICAL_KEY,
371 rootPath: "/workspace/repo",
372 locator: {
373 source: "git-remote",
374 remoteName: "origin",
375 remoteUrl: "https://github.com/example/shared-repo.git",
376 },
377 },
378 });
379 const remote = makeProject({
380 id: sharedProjectRemoteId,
381 environmentId: remoteEnvId,
382 name: "web",
383 cwd: "/srv/checkout/apps/web",
384 repositoryIdentity: {
385 canonicalKey: SHARED_REPO_CANONICAL_KEY,
386 rootPath: "/srv/checkout",
387 locator: {
388 source: "git-remote",
389 remoteName: "origin",
390 remoteUrl: "https://github.com/example/shared-repo.git",
391 },
392 },
393 });
394
395 expect(
396 deriveLogicalProjectKey(primary, {
397 groupingMode: "repository_path",
398 }),
399 ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`);
400 expect(
401 deriveLogicalProjectKey(primary, {
402 groupingMode: "repository_path",
403 }),
404 ).toBe(
405 deriveLogicalProjectKey(remote, {
406 groupingMode: "repository_path",
407 }),
408 );
409 });
410
276411 it("does NOT group projects without shared canonical key", () => {
277412 const local = makeProject({
278413 id: localOnlyProjectId,
......@@ -286,6 +421,32 @@ describe("environment grouping", () => {
286421 });
287422 expect(deriveLogicalProjectKey(local)).not.toBe(deriveLogicalProjectKey(remote));
288423 });
424
425 it("uses per-project overrides from settings", () => {
426 const project = makeProject({
427 id: sharedProjectPrimaryId,
428 environmentId: primaryEnvId,
429 name: "shared-repo",
430 repositoryIdentity: {
431 canonicalKey: SHARED_REPO_CANONICAL_KEY,
432 locator: {
433 source: "git-remote",
434 remoteName: "origin",
435 remoteUrl: "https://github.com/example/shared-repo.git",
436 },
437 },
438 });
439
440 expect(resolveProjectGroupingMode(project, DEFAULT_GROUPING_SETTINGS)).toBe("repository");
441 expect(
442 deriveLogicalProjectKeyFromSettings(project, {
443 ...DEFAULT_GROUPING_SETTINGS,
444 sidebarProjectGroupingOverrides: {
445 [derivePhysicalProjectKey(project)]: "separate",
446 },
447 }),
448 ).toBe(derivePhysicalProjectKey(project));
449 });
289450 });
290451
291452 describe("selectProjectsAcrossEnvironments", () => {
apps/web/src/environments/runtime/service.ts+3-4
......@@ -13,9 +13,7 @@ import { Throttler } from "@tanstack/react-pacer";
1313import {
1414 createKnownEnvironment,
1515 getKnownEnvironmentWsBaseUrl,
16 scopedProjectKey,
1716 scopedThreadKey,
18 scopeProjectRef,
1917 scopeThreadRef,
2018} from "@t3tools/client-runtime";
2119
......@@ -62,6 +60,7 @@ import { useTerminalStateStore } from "~/terminalStateStore";
6260import { useUiStateStore } from "~/uiStateStore";
6361import { WsTransport } from "../../rpc/wsTransport";
6462import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient";
63import { derivePhysicalProjectKey } from "../../logicalProject";
6564
6665type EnvironmentServiceState = {
6766 readonly queryClient: QueryClient;
......@@ -470,7 +469,7 @@ function syncProjectUiFromStore() {
470469 const projects = selectProjectsAcrossEnvironments(useStore.getState());
471470 useUiStateStore.getState().syncProjects(
472471 projects.map((project) => ({
473 key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
472 key: derivePhysicalProjectKey(project),
474473 cwd: project.cwd,
475474 })),
476475 );
......@@ -543,7 +542,7 @@ function applyRecoveredEventBatch(
543542 const projects = selectProjectsAcrossEnvironments(useStore.getState());
544543 useUiStateStore.getState().syncProjects(
545544 projects.map((project) => ({
546 key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
545 key: derivePhysicalProjectKey(project),
547546 cwd: project.cwd,
548547 })),
549548 );
apps/web/src/hooks/useHandleNewThread.ts+8-3
......@@ -10,14 +10,19 @@ import {
1010} from "../composerDraftStore";
1111import { newDraftId, newThreadId } from "../lib/utils";
1212import { orderItemsByPreferredIds } from "../components/Sidebar.logic";
13import { deriveLogicalProjectKey } from "../logicalProject";
13import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
1414import { selectProjectsAcrossEnvironments, useStore } from "../store";
1515import { createThreadSelectorByRef } from "../storeSelectors";
1616import { resolveThreadRouteTarget } from "../threadRoutes";
1717import { useUiStateStore } from "../uiStateStore";
18import { useSettings } from "./useSettings";
1819
1920function useNewThreadState() {
2021 const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store)));
22 const projectGroupingSettings = useSettings((settings) => ({
23 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
24 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
25 }));
2126 const router = useRouter();
2227 const getCurrentRouteTarget = useCallback(() => {
2328 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
......@@ -48,7 +53,7 @@ function useNewThreadState() {
4853 candidate.environmentId === projectRef.environmentId,
4954 );
5055 const logicalProjectKey = project
51 ? deriveLogicalProjectKey(project)
56 ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)
5257 : scopedProjectKey(projectRef);
5358 const hasBranchOption = options?.branch !== undefined;
5459 const hasWorktreePathOption = options?.worktreePath !== undefined;
......@@ -129,7 +134,7 @@ function useNewThreadState() {
129134 });
130135 })();
131136 },
132 [getCurrentRouteTarget, router, projects],
137 [getCurrentRouteTarget, projectGroupingSettings, router, projects],
133138 );
134139}
135140
apps/web/src/localApi.test.ts+25-34
......@@ -528,13 +528,20 @@ describe("wsApi", () => {
528528 });
529529
530530 it("reads and writes persistence through the desktop bridge when available", async () => {
531 const getClientSettings = vi.fn().mockResolvedValue({
531 const clientSettings = {
532532 confirmThreadArchive: true,
533533 confirmThreadDelete: false,
534534 diffWordWrap: true,
535 sidebarProjectSortOrder: "manual",
536 sidebarThreadSortOrder: "created_at",
537 timestampFormat: "24-hour",
535 sidebarProjectGroupingMode: "repository_path" as const,
536 sidebarProjectGroupingOverrides: {
537 "environment-local:/tmp/project": "separate" as const,
538 },
539 sidebarProjectSortOrder: "manual" as const,
540 sidebarThreadSortOrder: "created_at" as const,
541 timestampFormat: "24-hour" as const,
542 };
543 const getClientSettings = vi.fn().mockResolvedValue({
544 ...clientSettings,
538545 });
539546 const setClientSettings = vi.fn().mockResolvedValue(undefined);
540547 const getSavedEnvironmentRegistry = vi.fn().mockResolvedValue([]);
......@@ -556,14 +563,7 @@ describe("wsApi", () => {
556563 const api = createLocalApi(rpcClientMock as never);
557564
558565 await api.persistence.getClientSettings();
559 await api.persistence.setClientSettings({
560 confirmThreadArchive: true,
561 confirmThreadDelete: false,
562 diffWordWrap: true,
563 sidebarProjectSortOrder: "manual",
564 sidebarThreadSortOrder: "created_at",
565 timestampFormat: "24-hour",
566 });
566 await api.persistence.setClientSettings(clientSettings);
567567 await api.persistence.getSavedEnvironmentRegistry();
568568 await api.persistence.setSavedEnvironmentRegistry([]);
569569 await api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local"));
......@@ -574,14 +574,7 @@ describe("wsApi", () => {
574574 await api.persistence.removeSavedEnvironmentSecret(EnvironmentId.make("environment-local"));
575575
576576 expect(getClientSettings).toHaveBeenCalledWith();
577 expect(setClientSettings).toHaveBeenCalledWith({
578 confirmThreadArchive: true,
579 confirmThreadDelete: false,
580 diffWordWrap: true,
581 sidebarProjectSortOrder: "manual",
582 sidebarThreadSortOrder: "created_at",
583 timestampFormat: "24-hour",
584 });
577 expect(setClientSettings).toHaveBeenCalledWith(clientSettings);
585578 expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith();
586579 expect(setSavedEnvironmentRegistry).toHaveBeenCalledWith([]);
587580 expect(getSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local");
......@@ -592,15 +585,20 @@ describe("wsApi", () => {
592585 it("falls back to browser storage for persistence when the desktop bridge is missing", async () => {
593586 const { createLocalApi } = await import("./localApi");
594587 const api = createLocalApi(rpcClientMock as never);
595
596 await api.persistence.setClientSettings({
588 const clientSettings = {
597589 confirmThreadArchive: true,
598590 confirmThreadDelete: false,
599591 diffWordWrap: true,
600 sidebarProjectSortOrder: "manual",
601 sidebarThreadSortOrder: "created_at",
602 timestampFormat: "24-hour",
603 });
592 sidebarProjectGroupingMode: "repository_path" as const,
593 sidebarProjectGroupingOverrides: {
594 "environment-local:/tmp/project": "separate" as const,
595 },
596 sidebarProjectSortOrder: "manual" as const,
597 sidebarThreadSortOrder: "created_at" as const,
598 timestampFormat: "24-hour" as const,
599 };
600
601 await api.persistence.setClientSettings(clientSettings);
604602 await api.persistence.setSavedEnvironmentRegistry([
605603 {
606604 environmentId: EnvironmentId.make("environment-local"),
......@@ -616,14 +614,7 @@ describe("wsApi", () => {
616614 "bearer-token",
617615 );
618616
619 await expect(api.persistence.getClientSettings()).resolves.toEqual({
620 confirmThreadArchive: true,
621 confirmThreadDelete: false,
622 diffWordWrap: true,
623 sidebarProjectSortOrder: "manual",
624 sidebarThreadSortOrder: "created_at",
625 timestampFormat: "24-hour",
626 });
617 await expect(api.persistence.getClientSettings()).resolves.toEqual(clientSettings);
627618 await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([
628619 {
629620 environmentId: EnvironmentId.make("environment-local"),
apps/web/src/logicalProject.ts+143-5
......@@ -1,19 +1,157 @@
11import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime";
2import type { ScopedProjectRef } from "@t3tools/contracts";
2import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
3import { normalizeProjectPathForComparison } from "./lib/projectPaths";
34import type { Project } from "./types";
45
6export interface ProjectGroupingSettings {
7 sidebarProjectGroupingMode: SidebarProjectGroupingMode;
8 sidebarProjectGroupingOverrides: Record<string, SidebarProjectGroupingMode>;
9}
10
11export type ProjectGroupingMode = SidebarProjectGroupingMode;
12
13function uniqueNonEmptyValues(values: ReadonlyArray<string | null | undefined>): string[] {
14 const seen = new Set<string>();
15 const unique: string[] = [];
16 for (const value of values) {
17 const trimmed = value?.trim();
18 if (!trimmed || seen.has(trimmed)) {
19 continue;
20 }
21 seen.add(trimmed);
22 unique.push(trimmed);
23 }
24 return unique;
25}
26
27function deriveRepositoryRelativeProjectPath(
28 project: Pick<Project, "cwd" | "repositoryIdentity">,
29): string | null {
30 const rootPath = project.repositoryIdentity?.rootPath?.trim();
31 if (!rootPath) {
32 return null;
33 }
34
35 const normalizedProjectPath = normalizeProjectPathForComparison(project.cwd);
36 const normalizedRootPath = normalizeProjectPathForComparison(rootPath);
37 if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) {
38 return null;
39 }
40
41 if (normalizedProjectPath === normalizedRootPath) {
42 return "";
43 }
44
45 const separator = normalizedRootPath.includes("\\") ? "\\" : "/";
46 const rootPrefix = `${normalizedRootPath}${separator}`;
47 if (!normalizedProjectPath.startsWith(rootPrefix)) {
48 return null;
49 }
50
51 return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/");
52}
53
54export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string {
55 return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`;
56}
57
58export function derivePhysicalProjectKey(project: Pick<Project, "environmentId" | "cwd">): string {
59 return derivePhysicalProjectKeyFromPath(project.environmentId, project.cwd);
60}
61
62export function deriveProjectGroupingOverrideKey(
63 project: Pick<Project, "environmentId" | "cwd">,
64): string {
65 return derivePhysicalProjectKey(project);
66}
67
68export function resolveProjectGroupingMode(
69 project: Pick<Project, "environmentId" | "cwd">,
70 settings: ProjectGroupingSettings,
71): SidebarProjectGroupingMode {
72 return (
73 settings.sidebarProjectGroupingOverrides[deriveProjectGroupingOverrideKey(project)] ??
74 settings.sidebarProjectGroupingMode
75 );
76}
77
78function deriveRepositoryScopedKey(
79 project: Pick<Project, "cwd" | "repositoryIdentity">,
80 groupingMode: SidebarProjectGroupingMode,
81): string | null {
82 const canonicalKey = project.repositoryIdentity?.canonicalKey;
83 if (!canonicalKey) {
84 return null;
85 }
86
87 if (groupingMode === "repository") {
88 return canonicalKey;
89 }
90
91 const relativeProjectPath = deriveRepositoryRelativeProjectPath(project);
92 if (relativeProjectPath === null) {
93 return canonicalKey;
94 }
95
96 return relativeProjectPath.length === 0
97 ? canonicalKey
98 : `${canonicalKey}::${relativeProjectPath}`;
99}
100
5101export function deriveLogicalProjectKey(
6 project: Pick<Project, "environmentId" | "id" | "repositoryIdentity">,
102 project: Pick<Project, "environmentId" | "id" | "cwd" | "repositoryIdentity">,
103 options?: {
104 groupingMode?: SidebarProjectGroupingMode;
105 },
7106): string {
107 const groupingMode = options?.groupingMode ?? "repository";
108 if (groupingMode === "separate") {
109 return derivePhysicalProjectKey(project);
110 }
111
8112 return (
9 project.repositoryIdentity?.canonicalKey ??
113 deriveRepositoryScopedKey(project, groupingMode) ??
114 derivePhysicalProjectKey(project) ??
10115 scopedProjectKey(scopeProjectRef(project.environmentId, project.id))
11116 );
12117}
13118
119export function deriveLogicalProjectKeyFromSettings(
120 project: Pick<Project, "environmentId" | "id" | "cwd" | "repositoryIdentity">,
121 settings: ProjectGroupingSettings,
122): string {
123 return deriveLogicalProjectKey(project, {
124 groupingMode: resolveProjectGroupingMode(project, settings),
125 });
126}
127
14128export function deriveLogicalProjectKeyFromRef(
15129 projectRef: ScopedProjectRef,
16 project: Pick<Project, "repositoryIdentity"> | null | undefined,
130 project: Pick<Project, "environmentId" | "id" | "cwd" | "repositoryIdentity"> | null | undefined,
131 options?: {
132 groupingMode?: SidebarProjectGroupingMode;
133 },
17134): string {
18 return project?.repositoryIdentity?.canonicalKey ?? scopedProjectKey(projectRef);
135 return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef);
136}
137
138export function deriveProjectGroupLabel(input: {
139 representative: Pick<Project, "name" | "repositoryIdentity">;
140 members: ReadonlyArray<Pick<Project, "name" | "repositoryIdentity">>;
141}): string {
142 const sharedDisplayNames = uniqueNonEmptyValues(
143 input.members.map((member) => member.repositoryIdentity?.displayName),
144 );
145 if (sharedDisplayNames.length === 1) {
146 return sharedDisplayNames[0]!;
147 }
148
149 const sharedRepositoryNames = uniqueNonEmptyValues(
150 input.members.map((member) => member.repositoryIdentity?.name),
151 );
152 if (sharedRepositoryNames.length === 1) {
153 return sharedRepositoryNames[0]!;
154 }
155
156 return input.representative.name;
19157}
apps/web/src/routes/__root.tsx+23-7
......@@ -22,6 +22,11 @@ import { Button } from "../components/ui/button";
2222import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
2323import { resolveAndPersistPreferredEditor } from "../editorPreferences";
2424import { readLocalApi } from "../localApi";
25import { useSettings } from "../hooks/useSettings";
26import {
27 deriveLogicalProjectKeyFromSettings,
28 derivePhysicalProjectKeyFromPath,
29} from "../logicalProject";
2530import {
2631 getServerConfigUpdatedNotification,
2732 ServerConfigUpdatedNotification,
......@@ -204,6 +209,10 @@ function EventRouter() {
204209 const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId);
205210 const navigate = useNavigate();
206211 const pathname = useLocation({ select: (loc) => loc.pathname });
212 const projectGroupingSettings = useSettings((settings) => ({
213 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
214 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
215 }));
207216 const readPathname = useEffectEvent(() => pathname);
208217 const handledBootstrapThreadIdRef = useRef<string | null>(null);
209218 const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0);
......@@ -224,14 +233,21 @@ function EventRouter() {
224233 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
225234 return;
226235 }
227 useUiStateStore
228 .getState()
229 .setProjectExpanded(
230 scopedProjectKey(
231 scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId),
232 ),
233 true,
236 const bootstrapEnvironmentState =
237 useStore.getState().environmentStateById[payload.environment.environmentId];
238 const bootstrapProject =
239 bootstrapEnvironmentState?.projectById[payload.bootstrapProjectId] ?? null;
240 const bootstrapProjectKey =
241 (bootstrapProject
242 ? deriveLogicalProjectKeyFromSettings(bootstrapProject, projectGroupingSettings)
243 : null) ??
244 (serverConfig?.cwd
245 ? derivePhysicalProjectKeyFromPath(payload.environment.environmentId, serverConfig.cwd)
246 : null) ??
247 scopedProjectKey(
248 scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId),
234249 );
250 useUiStateStore.getState().setProjectExpanded(bootstrapProjectKey, true);
235251
236252 if (readPathname() !== "/") {
237253 return;
apps/web/src/sidebarProjectGrouping.ts created+118
......@@ -0,0 +1,118 @@
1import { scopeProjectRef } from "@t3tools/client-runtime";
2import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts";
3import {
4 deriveLogicalProjectKeyFromSettings,
5 derivePhysicalProjectKey,
6 deriveProjectGroupLabel,
7 type ProjectGroupingSettings,
8} from "./logicalProject";
9import type { Project } from "./types";
10
11export type EnvironmentPresence = "local-only" | "remote-only" | "mixed";
12
13export interface SidebarProjectGroupMember extends Project {
14 physicalProjectKey: string;
15 environmentLabel: string | null;
16}
17
18export interface SidebarProjectSnapshot extends Project {
19 projectKey: string;
20 displayName: string;
21 groupedProjectCount: number;
22 environmentPresence: EnvironmentPresence;
23 memberProjects: readonly SidebarProjectGroupMember[];
24 memberProjectRefs: readonly ScopedProjectRef[];
25 remoteEnvironmentLabels: readonly string[];
26}
27
28export function buildPhysicalToLogicalProjectKeyMap(input: {
29 projects: ReadonlyArray<Project>;
30 settings: ProjectGroupingSettings;
31}): Map<string, string> {
32 const mapping = new Map<string, string>();
33 for (const project of input.projects) {
34 mapping.set(
35 derivePhysicalProjectKey(project),
36 deriveLogicalProjectKeyFromSettings(project, input.settings),
37 );
38 }
39 return mapping;
40}
41
42export function buildSidebarProjectSnapshots(input: {
43 projects: ReadonlyArray<Project>;
44 settings: ProjectGroupingSettings;
45 primaryEnvironmentId: EnvironmentId | null;
46 resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null;
47}): SidebarProjectSnapshot[] {
48 const groupedMembers = new Map<string, SidebarProjectGroupMember[]>();
49 for (const project of input.projects) {
50 const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings);
51 const member: SidebarProjectGroupMember = {
52 ...project,
53 physicalProjectKey: derivePhysicalProjectKey(project),
54 environmentLabel: input.resolveEnvironmentLabel(project.environmentId),
55 };
56 const existing = groupedMembers.get(logicalKey);
57 if (existing) {
58 existing.push(member);
59 } else {
60 groupedMembers.set(logicalKey, [member]);
61 }
62 }
63
64 const result: SidebarProjectSnapshot[] = [];
65 const seen = new Set<string>();
66 for (const project of input.projects) {
67 const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings);
68 if (seen.has(logicalKey)) {
69 continue;
70 }
71 seen.add(logicalKey);
72
73 const members = groupedMembers.get(logicalKey) ?? [];
74 const representative =
75 (input.primaryEnvironmentId
76 ? members.find((member) => member.environmentId === input.primaryEnvironmentId)
77 : null) ?? members[0];
78 if (!representative) {
79 continue;
80 }
81
82 const hasLocal =
83 input.primaryEnvironmentId !== null &&
84 members.some((member) => member.environmentId === input.primaryEnvironmentId);
85 const hasRemote =
86 input.primaryEnvironmentId !== null
87 ? members.some((member) => member.environmentId !== input.primaryEnvironmentId)
88 : false;
89 const remoteEnvironmentLabels = members
90 .filter(
91 (member) =>
92 input.primaryEnvironmentId !== null &&
93 member.environmentId !== input.primaryEnvironmentId,
94 )
95 .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : []))
96 .filter((label, index, labels) => labels.indexOf(label) === index);
97
98 result.push({
99 ...representative,
100 projectKey: logicalKey,
101 displayName:
102 members.length > 1
103 ? deriveProjectGroupLabel({
104 representative,
105 members,
106 })
107 : representative.name,
108 groupedProjectCount: members.length,
109 environmentPresence:
110 hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only",
111 memberProjects: members,
112 memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)),
113 remoteEnvironmentLabels,
114 });
115 }
116
117 return result;
118}
packages/contracts/src/environment.ts+1
......@@ -51,6 +51,7 @@ export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type;
5151export const RepositoryIdentity = Schema.Struct({
5252 canonicalKey: TrimmedNonEmptyString,
5353 locator: RepositoryIdentityLocator,
54 rootPath: Schema.optionalKey(TrimmedNonEmptyString),
5455 displayName: Schema.optionalKey(TrimmedNonEmptyString),
5556 provider: Schema.optionalKey(TrimmedNonEmptyString),
5657 owner: Schema.optionalKey(TrimmedNonEmptyString),
packages/contracts/src/ipc.ts+1
......@@ -60,6 +60,7 @@ export interface ContextMenuItem<T extends string = string> {
6060 label: string;
6161 destructive?: boolean;
6262 disabled?: boolean;
63 children?: readonly ContextMenuItem<T>[];
6364}
6465
6566export type DesktopUpdateStatus =
packages/contracts/src/settings.ts+15
......@@ -23,10 +23,25 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at
2323export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;
2424export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";
2525
26export const SidebarProjectGroupingMode = Schema.Literals([
27 "repository",
28 "repository_path",
29 "separate",
30]);
31export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type;
32export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository";
33
2634export const ClientSettingsSchema = Schema.Struct({
2735 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
2836 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
2937 diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
38 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
39 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
40 ),
41 sidebarProjectGroupingOverrides: Schema.Record(
42 TrimmedNonEmptyString,
43 SidebarProjectGroupingMode,
44 ).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
3045 sidebarProjectSortOrder: SidebarProjectSortOrder.pipe(
3146 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)),
3247 ),