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 = {...@@ -52,6 +52,10 @@ const clientSettings: ClientSettings = {
52 confirmThreadArchive: true,52 confirmThreadArchive: true,
53 confirmThreadDelete: false,53 confirmThreadDelete: false,
54 diffWordWrap: true,54 diffWordWrap: true,
55 sidebarProjectGroupingMode: "repository_path",
56 sidebarProjectGroupingOverrides: {
57 "environment-1:/tmp/project-a": "separate",
58 },
55 sidebarProjectSortOrder: "manual",59 sidebarProjectSortOrder: "manual",
56 sidebarThreadSortOrder: "created_at",60 sidebarThreadSortOrder: "created_at",
57 timestampFormat: "24-hour",61 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...@@ -160,6 +160,35 @@ const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linu
160const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";160const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
161const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";161const 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
163type WindowTitleBarOptions = Pick<192type WindowTitleBarOptions = Pick<
164 BrowserWindowConstructorOptions,193 BrowserWindowConstructorOptions,
165 "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition"194 "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition"
...@@ -1715,14 +1744,7 @@ function registerIpcHandlers(): void {...@@ -1715,14 +1744,7 @@ function registerIpcHandlers(): void {
1715 ipcMain.handle(1744 ipcMain.handle(
1716 CONTEXT_MENU_CHANNEL,1745 CONTEXT_MENU_CHANNEL,
1717 async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => {1746 async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => {
1718 const normalizedItems = items1747 const normalizedItems = normalizeContextMenuItems(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 }));
1726 if (normalizedItems.length === 0) {1748 if (normalizedItems.length === 0) {
1727 return null;1749 return null;
1728 }1750 }
...@@ -1743,28 +1765,37 @@ function registerIpcHandlers(): void {...@@ -1743,28 +1765,37 @@ function registerIpcHandlers(): void {
1743 if (!window) return null;1765 if (!window) return null;
17441766
1745 return new Promise<string | null>((resolve) => {1767 return new Promise<string | null>((resolve) => {
1746 const template: MenuItemConstructorOptions[] = [];1768 const buildTemplate = (
1747 let hasInsertedDestructiveSeparator = false;1769 entries: readonly ContextMenuItem[],
1748 for (const item of normalizedItems) {1770 ): MenuItemConstructorOptions[] => {
1749 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {1771 const template: MenuItemConstructorOptions[] = [];
1750 template.push({ type: "separator" });1772 let hasInsertedDestructiveSeparator = false;
1751 hasInsertedDestructiveSeparator = true;1773 for (const item of entries) {
1752 }1774 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
1753 const itemOption: MenuItemConstructorOptions = {1775 template.push({ type: "separator" });
1754 label: item.label,1776 hasInsertedDestructiveSeparator = true;
1755 enabled: !item.disabled,1777 }
1756 click: () => resolve(item.id),1778 const itemOption: MenuItemConstructorOptions = {
1757 };1779 label: item.label,
1758 if (item.destructive) {1780 enabled: !item.disabled,
1759 const destructiveIcon = getDestructiveMenuIcon();1781 };
1760 if (destructiveIcon) {1782 if (item.children && item.children.length > 0) {
1761 itemOption.icon = destructiveIcon;1783 itemOption.submenu = buildTemplate(item.children);
1784 } else {
1785 itemOption.click = () => resolve(item.id);
1762 }1786 }
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);
1763 }1794 }
1764 template.push(itemOption);1795 return template;
1765 }1796 };
17661797
1767 const menu = Menu.buildFromTemplate(template);1798 const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems));
1768 menu.popup({1799 menu.popup({
1769 window,1800 window,
1770 ...popupPosition,1801 ...popupPosition,
apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts+28
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { realpathSync } from "node:fs";
2
1import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";4import { expect, it } from "@effect/vitest";
3import { Duration, Effect, FileSystem, Layer } from "effect";5import { Duration, Effect, FileSystem, Layer } from "effect";
...@@ -10,6 +12,10 @@ import {...@@ -10,6 +12,10 @@ import {
10 RepositoryIdentityResolverLive,12 RepositoryIdentityResolverLive,
11} from "./RepositoryIdentityResolver.ts";13} from "./RepositoryIdentityResolver.ts";
1214
15const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/");
16const normalizeResolvedPath = (value: string) =>
17 normalizePathSeparators(realpathSync.native(value));
18
13const git = (cwd: string, args: ReadonlyArray<string>) =>19const git = (cwd: string, args: ReadonlyArray<string>) =>
14 Effect.promise(() => runProcess("git", ["-C", cwd, ...args]));20 Effect.promise(() => runProcess("git", ["-C", cwd, ...args]));
1521
...@@ -41,6 +47,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {...@@ -41,6 +47,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
4147
42 expect(identity).not.toBeNull();48 expect(identity).not.toBeNull();
43 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");49 expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
50 expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(cwd));
44 expect(identity?.displayName).toBe("t3tools/t3code");51 expect(identity?.displayName).toBe("t3tools/t3code");
45 expect(identity?.provider).toBe("github");52 expect(identity?.provider).toBe("github");
46 expect(identity?.owner).toBe("t3tools");53 expect(identity?.owner).toBe("t3tools");
...@@ -48,6 +55,27 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {...@@ -48,6 +55,27 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
48 }).pipe(Effect.provide(RepositoryIdentityResolverLive)),55 }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
49 );56 );
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
51 it.effect("returns null for non-git folders and repos without remotes", () =>79 it.effect("returns null for non-git folders and repos without remotes", () =>
52 Effect.gen(function* () {80 Effect.gen(function* () {
53 const fileSystem = yield* FileSystem.FileSystem;81 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/project/Layers/RepositoryIdentityResolver.ts+3-1
...@@ -42,6 +42,7 @@ function pickPrimaryRemote(...@@ -42,6 +42,7 @@ function pickPrimaryRemote(
42function buildRepositoryIdentity(input: {42function buildRepositoryIdentity(input: {
43 readonly remoteName: string;43 readonly remoteName: string;
44 readonly remoteUrl: string;44 readonly remoteUrl: string;
45 readonly rootPath: string;
45}): RepositoryIdentity {46}): RepositoryIdentity {
46 const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl);47 const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl);
47 const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl);48 const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl);
...@@ -57,6 +58,7 @@ function buildRepositoryIdentity(input: {...@@ -57,6 +58,7 @@ function buildRepositoryIdentity(input: {
57 remoteName: input.remoteName,58 remoteName: input.remoteName,
58 remoteUrl: input.remoteUrl,59 remoteUrl: input.remoteUrl,
59 },60 },
61 rootPath: input.rootPath,
60 ...(repositoryPath ? { displayName: repositoryPath } : {}),62 ...(repositoryPath ? { displayName: repositoryPath } : {}),
61 ...(hostingProvider ? { provider: hostingProvider.kind } : {}),63 ...(hostingProvider ? { provider: hostingProvider.kind } : {}),
62 ...(owner ? { owner } : {}),64 ...(owner ? { owner } : {}),
...@@ -108,7 +110,7 @@ async function resolveRepositoryIdentityFromCacheKey(...@@ -108,7 +110,7 @@ async function resolveRepositoryIdentityFromCacheKey(
108 }110 }
109111
110 const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout));112 const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout));
111 return remote ? buildRepositoryIdentity(remote) : null;113 return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null;
112 } catch {114 } catch {
113 return null;115 return null;
114 }116 }
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+4-4
...@@ -351,7 +351,7 @@ describe("ClaudeAdapterLive", () => {...@@ -351,7 +351,7 @@ describe("ClaudeAdapterLive", () => {
351 );351 );
352 });352 });
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", () => {
355 const harness = makeHarness();355 const harness = makeHarness();
356 return Effect.gen(function* () {356 return Effect.gen(function* () {
357 const adapter = yield* ClaudeAdapter;357 const adapter = yield* ClaudeAdapter;
...@@ -366,14 +366,14 @@ describe("ClaudeAdapterLive", () => {...@@ -366,14 +366,14 @@ describe("ClaudeAdapterLive", () => {
366 });366 });
367367
368 const createInput = harness.getLastCreateQueryInput();368 const createInput = harness.getLastCreateQueryInput();
369 assert.equal(createInput?.options.effort, "xhigh");369 assert.equal(createInput?.options.effort, "max");
370 }).pipe(370 }).pipe(
371 Effect.provideService(Random.Random, makeDeterministicRandomService()),371 Effect.provideService(Random.Random, makeDeterministicRandomService()),
372 Effect.provide(harness.layer),372 Effect.provide(harness.layer),
373 );373 );
374 });374 });
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", () => {
377 const harness = makeHarness();377 const harness = makeHarness();
378 return Effect.gen(function* () {378 return Effect.gen(function* () {
379 const adapter = yield* ClaudeAdapter;379 const adapter = yield* ClaudeAdapter;
...@@ -391,7 +391,7 @@ describe("ClaudeAdapterLive", () => {...@@ -391,7 +391,7 @@ describe("ClaudeAdapterLive", () => {
391 });391 });
392392
393 const createInput = harness.getLastCreateQueryInput();393 const createInput = harness.getLastCreateQueryInput();
394 assert.equal(createInput?.options.effort, "xhigh");394 assert.equal(createInput?.options.effort, "max");
395 }).pipe(395 }).pipe(
396 Effect.provideService(Random.Random, makeDeterministicRandomService()),396 Effect.provideService(Random.Random, makeDeterministicRandomService()),
397 Effect.provide(harness.layer),397 Effect.provide(harness.layer),
apps/server/src/provider/Layers/ClaudeAdapter.ts+9-2
...@@ -84,6 +84,7 @@ type ClaudeToolResultStreamKind = Extract<...@@ -84,6 +84,7 @@ type ClaudeToolResultStreamKind = Extract<
84 RuntimeContentStreamKind,84 RuntimeContentStreamKind,
85 "command_output" | "file_change_output"85 "command_output" | "file_change_output"
86>;86>;
87type ClaudeSdkEffort = NonNullable<ClaudeQueryOptions["effort"]>;
8788
88type PromptQueueItem =89type PromptQueueItem =
89 | {90 | {
...@@ -217,11 +218,17 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause<Error>): ReadonlyArray...@@ -217,11 +218,17 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause<Error>): ReadonlyArray
217218
218function getEffectiveClaudeAgentEffort(219function getEffectiveClaudeAgentEffort(
219 effort: ClaudeAgentEffort | null | undefined,220 effort: ClaudeAgentEffort | null | undefined,
220): Exclude<ClaudeAgentEffort, "ultrathink"> | null {221): ClaudeSdkEffort | null {
221 if (!effort) {222 if (!effort) {
222 return null;223 return null;
223 }224 }
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;
225}232}
226233
227function isClaudeInterruptedMessage(message: string): boolean {234function isClaudeInterruptedMessage(message: string): boolean {
apps/web/src/components/ChatView.browser.tsx+18-11
...@@ -17,12 +17,7 @@ import {...@@ -17,12 +17,7 @@ import {
17 OrchestrationSessionStatus,17 OrchestrationSessionStatus,
18 DEFAULT_SERVER_SETTINGS,18 DEFAULT_SERVER_SETTINGS,
19} from "@t3tools/contracts";19} from "@t3tools/contracts";
20import {20import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
21 scopedProjectKey,
22 scopedThreadKey,
23 scopeProjectRef,
24 scopeThreadRef,
25} from "@t3tools/client-runtime";
26import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";21import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
27import { HttpResponse, http, ws } from "msw";22import { HttpResponse, http, ws } from "msw";
28import { setupWorker } from "msw/browser";23import { setupWorker } from "msw/browser";
...@@ -52,6 +47,7 @@ import { __resetLocalApiForTests } from "../localApi";...@@ -52,6 +47,7 @@ import { __resetLocalApiForTests } from "../localApi";
52import { AppAtomRegistryProvider } from "../rpc/atomRegistry";47import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
53import { getServerConfig } from "../rpc/serverState";48import { getServerConfig } from "../rpc/serverState";
54import { getRouter } from "../router";49import { getRouter } from "../router";
50import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
55import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store";51import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store";
56import { useTerminalStateStore } from "../terminalStateStore";52import { useTerminalStateStore } from "../terminalStateStore";
57import { useUiStateStore } from "../uiStateStore";53import { useUiStateStore } from "../uiStateStore";
...@@ -78,7 +74,18 @@ const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID);...@@ -78,7 +74,18 @@ const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID);
78const THREAD_KEY = scopedThreadKey(THREAD_REF);74const THREAD_KEY = scopedThreadKey(THREAD_REF);
79const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;75const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
80const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`;76const 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);
82const NOW_ISO = "2026-03-04T12:00:00.000Z";89const NOW_ISO = "2026-03-04T12:00:00.000Z";
83const BASE_TIME_MS = Date.parse(NOW_ISO);90const BASE_TIME_MS = Date.parse(NOW_ISO);
84const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'></svg>";91const 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)", () => {...@@ -1638,12 +1645,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
1638 customWsRpcResolver = null;1645 customWsRpcResolver = null;
1639 document.body.innerHTML = "";1646 document.body.innerHTML = "";
1640 });1647 });
1641 it("re-expands the bootstrap project using its scoped key", async () => {1648 it("re-expands the bootstrap project using its logical key", async () => {
1642 useUiStateStore.setState({1649 useUiStateStore.setState({
1643 projectExpandedById: {1650 projectExpandedById: {
1644 [PROJECT_KEY]: false,1651 [PROJECT_LOGICAL_KEY]: false,
1645 },1652 },
1646 projectOrder: [PROJECT_KEY],1653 projectOrder: [PROJECT_LOGICAL_KEY],
1647 threadLastVisitedAtById: {},1654 threadLastVisitedAtById: {},
1648 });1655 });
16491656
...@@ -1658,7 +1665,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1658,7 +1665,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
1658 try {1665 try {
1659 await vi.waitFor(1666 await vi.waitFor(
1660 () => {1667 () => {
1661 expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true);1668 expect(useUiStateStore.getState().projectExpandedById[PROJECT_LOGICAL_KEY]).toBe(true);
1662 },1669 },
1663 { timeout: 8_000, interval: 16 },1670 { timeout: 8_000, interval: 16 },
1664 );1671 );
apps/web/src/components/ChatView.tsx+15-4
...@@ -113,7 +113,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov...@@ -113,7 +113,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov
113import { useSettings } from "../hooks/useSettings";113import { useSettings } from "../hooks/useSettings";
114import { resolveAppModelSelection } from "../modelSelection";114import { resolveAppModelSelection } from "../modelSelection";
115import { isTerminalFocused } from "../lib/terminalFocus";115import { isTerminalFocused } from "../lib/terminalFocus";
116import { deriveLogicalProjectKey } from "../logicalProject";116import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
117import {117import {
118 useSavedEnvironmentRegistryStore,118 useSavedEnvironmentRegistryStore,
119 useSavedEnvironmentRuntimeStore,119 useSavedEnvironmentRuntimeStore,
...@@ -847,10 +847,16 @@ export default function ChatView(props: ChatViewProps) {...@@ -847,10 +847,16 @@ export default function ChatView(props: ChatViewProps) {
847 const primaryEnvironmentId = usePrimaryEnvironmentId();847 const primaryEnvironmentId = usePrimaryEnvironmentId();
848 const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId);848 const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId);
849 const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId);849 const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId);
850 const projectGroupingSettings = useSettings((settings) => ({
851 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
852 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
853 }));
850 const logicalProjectEnvironments = useMemo(() => {854 const logicalProjectEnvironments = useMemo(() => {
851 if (!activeProject) return [];855 if (!activeProject) return [];
852 const logicalKey = deriveLogicalProjectKey(activeProject);856 const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings);
853 const memberProjects = allProjects.filter((p) => deriveLogicalProjectKey(p) === logicalKey);857 const memberProjects = allProjects.filter(
858 (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey,
859 );
854 const seen = new Set<string>();860 const seen = new Set<string>();
855 const envs: Array<{861 const envs: Array<{
856 environmentId: EnvironmentId;862 environmentId: EnvironmentId;
...@@ -886,6 +892,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -886,6 +892,7 @@ export default function ChatView(props: ChatViewProps) {
886 }, [892 }, [
887 activeProject,893 activeProject,
888 allProjects,894 allProjects,
895 projectGroupingSettings,
889 primaryEnvironmentId,896 primaryEnvironmentId,
890 savedEnvironmentRegistry,897 savedEnvironmentRegistry,
891 savedEnvironmentRuntimeById,898 savedEnvironmentRuntimeById,
...@@ -915,7 +922,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -915,7 +922,10 @@ export default function ChatView(props: ChatViewProps) {
915 throw new Error("No active project is available for this pull request.");922 throw new Error("No active project is available for this pull request.");
916 }923 }
917 const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id);924 const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id);
918 const logicalProjectKey = deriveLogicalProjectKey(activeProject);925 const logicalProjectKey = deriveLogicalProjectKeyFromSettings(
926 activeProject,
927 projectGroupingSettings,
928 );
919 const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey);929 const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey);
920 if (storedDraftSession) {930 if (storedDraftSession) {
921 setDraftThreadContext(storedDraftSession.draftId, input);931 setDraftThreadContext(storedDraftSession.draftId, input);
...@@ -976,6 +986,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -976,6 +986,7 @@ export default function ChatView(props: ChatViewProps) {
976 getDraftSessionByLogicalProjectKey,986 getDraftSessionByLogicalProjectKey,
977 isServerThread,987 isServerThread,
978 navigate,988 navigate,
989 projectGroupingSettings,
979 routeKind,990 routeKind,
980 setDraftThreadContext,991 setDraftThreadContext,
981 setLogicalProjectDraftThreadId,992 setLogicalProjectDraftThreadId,
apps/web/src/components/Sidebar.tsx+615-192
...@@ -31,10 +31,11 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-...@@ -31,10 +31,11 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-
31import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";31import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
32import { CSS } from "@dnd-kit/utilities";32import { CSS } from "@dnd-kit/utilities";
33import {33import {
34 type ContextMenuItem,
34 type DesktopUpdateState,35 type DesktopUpdateState,
35 ProjectId,36 ProjectId,
36 type ScopedProjectRef,
37 type ScopedThreadRef,37 type ScopedThreadRef,
38 type SidebarProjectGroupingMode,
38 type ThreadEnvMode,39 type ThreadEnvMode,
39 ThreadId,40 ThreadId,
40 type GitStatusResult,41 type GitStatusResult,
...@@ -59,7 +60,6 @@ import { isMacPlatform, newCommandId } from "../lib/utils";...@@ -59,7 +60,6 @@ import { isMacPlatform, newCommandId } from "../lib/utils";
59import {60import {
60 selectProjectByRef,61 selectProjectByRef,
61 selectProjectsAcrossEnvironments,62 selectProjectsAcrossEnvironments,
62 selectSidebarThreadsForProjectRef,
63 selectSidebarThreadsForProjectRefs,63 selectSidebarThreadsForProjectRefs,
64 selectSidebarThreadsAcrossEnvironments,64 selectSidebarThreadsAcrossEnvironments,
65 selectThreadByRef,65 selectThreadByRef,
...@@ -102,7 +102,26 @@ import {...@@ -102,7 +102,26 @@ import {
102} from "./desktopUpdate.logic";102} from "./desktopUpdate.logic";
103import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";103import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
104import { Button } from "./ui/button";104import { 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";
106import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";125import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
107import {126import {
108 SidebarContent,127 SidebarContent,
...@@ -142,12 +161,18 @@ import { CommandDialogTrigger } from "./ui/command";...@@ -142,12 +161,18 @@ import { CommandDialogTrigger } from "./ui/command";
142import { readEnvironmentApi } from "../environmentApi";161import { readEnvironmentApi } from "../environmentApi";
143import { useSettings, useUpdateSettings } from "~/hooks/useSettings";162import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
144import { useServerKeybindings } from "../rpc/serverState";163import { useServerKeybindings } from "../rpc/serverState";
145import { deriveLogicalProjectKey } from "../logicalProject";164import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey } from "../logicalProject";
146import {165import {
147 useSavedEnvironmentRegistryStore,166 useSavedEnvironmentRegistryStore,
148 useSavedEnvironmentRuntimeStore,167 useSavedEnvironmentRuntimeStore,
149} from "../environments/runtime";168} 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";
151const THREAD_PREVIEW_LIMIT = 6;176const THREAD_PREVIEW_LIMIT = 6;
152const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {177const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
153 updated_at: "Last user message",178 updated_at: "Last user message",
...@@ -163,6 +188,11 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {...@@ -163,6 +188,11 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {
163 easing: "ease-out",188 easing: "ease-out",
164} as const;189} as const;
165const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>();190const 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
167function threadJumpLabelMapsEqual(197function threadJumpLabelMapsEqual(
168 left: ReadonlyMap<string, string>,198 left: ReadonlyMap<string, string>,
...@@ -182,6 +212,28 @@ function threadJumpLabelMapsEqual(...@@ -182,6 +212,28 @@ function threadJumpLabelMapsEqual(
182 return true;212 return true;
183}213}
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
185function buildThreadJumpLabelMap(input: {237function buildThreadJumpLabelMap(input: {
186 keybindings: ReturnType<typeof useServerKeybindings>;238 keybindings: ReturnType<typeof useServerKeybindings>;
187 platform: string;239 platform: string;
...@@ -212,15 +264,6 @@ function buildThreadJumpLabelMap(input: {...@@ -212,15 +264,6 @@ function buildThreadJumpLabelMap(input: {
212 return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS;264 return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS;
213}265}
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};
224interface TerminalStatusIndicator {267interface TerminalStatusIndicator {
225 label: "Terminal process running";268 label: "Terminal process running";
226 colorClass: string;269 colorClass: string;
...@@ -996,6 +1039,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -996,6 +1039,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
996 const defaultThreadEnvMode = useSettings<ThreadEnvMode>(1039 const defaultThreadEnvMode = useSettings<ThreadEnvMode>(
997 (settings) => settings.defaultThreadEnvMode,1040 (settings) => settings.defaultThreadEnvMode,
998 );1041 );
1042 const projectGroupingSettings = useSettings((settings) => ({
1043 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
1044 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
1045 }));
1046 const { updateSettings } = useUpdateSettings();
999 const router = useRouter();1047 const router = useRouter();
1000 const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);1048 const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);
1001 const toggleProject = useUiStateStore((state) => state.toggleProject);1049 const toggleProject = useUiStateStore((state) => state.toggleProject);
...@@ -1073,58 +1121,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1073,58 +1121,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1073 useShallow(1121 useShallow(
1074 useMemo(1122 useMemo(
1075 () => (state: import("../store").AppState) =>1123 () => (state: import("../store").AppState) =>
1076 selectSidebarThreadsForProjectRef(1124 selectSidebarThreadsForProjectRefs(state, project.memberProjectRefs),
1077 state,1125 [project.memberProjectRefs],
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],
1102 ),1126 ),
1103 ),1127 ),
1104 );1128 );
1105 const allSidebarThreads = useMemo(
1106 () =>
1107 otherMemberThreads.length === 0 ? sidebarThreads : [...sidebarThreads, ...otherMemberThreads],
1108 [sidebarThreads, otherMemberThreads],
1109 );
1110 const sidebarThreadByKey = useMemo(1129 const sidebarThreadByKey = useMemo(
1111 () =>1130 () =>
1112 new Map(1131 new Map(
1113 allSidebarThreads.map(1132 sidebarThreads.map(
1114 (thread) =>1133 (thread) =>
1115 [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,1134 [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
1116 ),1135 ),
1117 ),1136 ),
1118 [allSidebarThreads],1137 [sidebarThreads],
1119 );1138 );
1120 // Keep a ref so callbacks can read the latest map without appearing in1139 // Keep a ref so callbacks can read the latest map without appearing in
1121 // dependency arrays (avoids invalidating every thread-row memo on each1140 // dependency arrays (avoids invalidating every thread-row memo on each
1122 // thread-list change).1141 // thread-list change).
1123 const sidebarThreadByKeyRef = useRef(sidebarThreadByKey);1142 const sidebarThreadByKeyRef = useRef(sidebarThreadByKey);
1124 sidebarThreadByKeyRef.current = sidebarThreadByKey;1143 sidebarThreadByKeyRef.current = sidebarThreadByKey;
1125 // All threads from the representative + other member environments are1144 const projectThreads = sidebarThreads;
1126 // already fetched into allSidebarThreads, so we can use them directly.
1127 const projectThreads = allSidebarThreads;
1128 const projectExpanded = useUiStateStore(1145 const projectExpanded = useUiStateStore(
1129 (state) => state.projectExpandedById[project.projectKey] ?? true,1146 (state) => state.projectExpandedById[project.projectKey] ?? true,
1130 );1147 );
...@@ -1141,9 +1158,43 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1141,9 +1158,43 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1141 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);1158 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
1142 const [renamingTitle, setRenamingTitle] = useState("");1159 const [renamingTitle, setRenamingTitle] = useState("");
1143 const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState<string | null>(null);1160 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");
1144 const renamingCommittedRef = useRef(false);1170 const renamingCommittedRef = useRef(false);
1145 const renamingInputRef = useRef<HTMLInputElement | null>(null);1171 const renamingInputRef = useRef<HTMLInputElement | null>(null);
1146 const confirmArchiveButtonRefs = useRef(new Map<string, HTMLButtonElement>());1172 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
1148 const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => {1199 const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => {
1149 const lastVisitedAtByThreadKey = new Map(1200 const lastVisitedAtByThreadKey = new Map(
...@@ -1318,6 +1369,88 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1318,6 +1369,88 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1318 [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef],1369 [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef],
1319 );1370 );
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
1321 const handleProjectButtonContextMenu = useCallback(1454 const handleProjectButtonContextMenu = useCallback(
1322 (event: React.MouseEvent<HTMLButtonElement>) => {1455 (event: React.MouseEvent<HTMLButtonElement>) => {
1323 event.preventDefault();1456 event.preventDefault();
...@@ -1326,73 +1459,103 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1326,73 +1459,103 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1326 const api = readLocalApi();1459 const api = readLocalApi();
1327 if (!api) return;1460 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
1329 const clicked = await api.contextMenu.show(1527 const clicked = await api.contextMenu.show(
1330 [1528 [
1331 { id: "copy-path", label: "Copy Project Path" },1529 buildTargetedItem("rename", "Rename project"),
1332 { id: "delete", label: "Remove project", destructive: true },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 }),
1333 ],1537 ],
1334 {1538 {
1335 x: event.clientX,1539 x: event.clientX,
1336 y: event.clientY,1540 y: event.clientY,
1337 },1541 },
1338 );1542 );
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) {1544 if (!clicked) {
1346 toastManager.add({
1347 type: "warning",
1348 title: "Project is not empty",
1349 description: "Delete all threads in this project before removing it.",
1350 });
1351 return;1545 return;
1352 }1546 }
13531547
1354 const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`);1548 await actionHandlers.get(clicked)?.();
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 }
1384 })();1549 })();
1385 },1550 },
1386 [1551 [
1387 clearComposerDraftForThread,
1388 clearProjectDraftThreadId,
1389 copyPathToClipboard,1552 copyPathToClipboard,
1390 getDraftThreadByProjectRef,1553 handleRemoveProject,
1391 project.cwd,1554 memberThreadCountByPhysicalKey,
1392 project.environmentId,1555 openProjectGroupingDialog,
1393 project.id,1556 openProjectRenameDialog,
1394 project.name,1557 project.groupedProjectCount,
1395 projectThreads.length,1558 project.memberProjects,
1396 suppressProjectClickForContextMenuRef,1559 suppressProjectClickForContextMenuRef,
1397 ],1560 ],
1398 );1561 );
...@@ -1503,10 +1666,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1503,10 +1666,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1503 ],1666 ],
1504 );1667 );
15051668
1506 const handleCreateThreadClick = useCallback(1669 const createThreadForProjectMember = useCallback(
1507 (event: React.MouseEvent<HTMLButtonElement>) => {1670 (member: SidebarProjectGroupMember) => {
1508 event.preventDefault();
1509 event.stopPropagation();
1510 const currentRouteParams =1671 const currentRouteParams =
1511 router.state.matches[router.state.matches.length - 1]?.params ?? {};1672 router.state.matches[router.state.matches.length - 1]?.params ?? {};
1512 const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams);1673 const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams);
...@@ -1522,12 +1683,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1522,12 +1683,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1522 ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null)1683 ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null)
1523 : null;1684 : null;
1524 const seedContext = resolveSidebarNewThreadSeedContext({1685 const seedContext = resolveSidebarNewThreadSeedContext({
1525 projectId: project.id,1686 projectId: member.id,
1526 defaultEnvMode: resolveSidebarNewThreadEnvMode({1687 defaultEnvMode: resolveSidebarNewThreadEnvMode({
1527 defaultEnvMode: defaultThreadEnvMode,1688 defaultEnvMode: defaultThreadEnvMode,
1528 }),1689 }),
1529 activeThread:1690 activeThread:
1530 currentActiveThread && currentActiveThread.projectId === project.id1691 currentActiveThread && currentActiveThread.projectId === member.id
1531 ? {1692 ? {
1532 projectId: currentActiveThread.projectId,1693 projectId: currentActiveThread.projectId,
1533 branch: currentActiveThread.branch,1694 branch: currentActiveThread.branch,
...@@ -1535,7 +1696,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1535,7 +1696,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1535 }1696 }
1536 : null,1697 : null,
1537 activeDraftThread:1698 activeDraftThread:
1538 currentActiveDraftThread && currentActiveDraftThread.projectId === project.id1699 currentActiveDraftThread && currentActiveDraftThread.projectId === member.id
1539 ? {1700 ? {
1540 projectId: currentActiveDraftThread.projectId,1701 projectId: currentActiveDraftThread.projectId,
1541 branch: currentActiveDraftThread.branch,1702 branch: currentActiveDraftThread.branch,
...@@ -1544,7 +1705,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1544,7 +1705,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1544 }1705 }
1545 : null,1706 : null,
1546 });1707 });
1547 void handleNewThread(scopeProjectRef(project.environmentId, project.id), {1708 void handleNewThread(scopeProjectRef(member.environmentId, member.id), {
1548 ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}),1709 ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}),
1549 ...(seedContext.worktreePath !== undefined1710 ...(seedContext.worktreePath !== undefined
1550 ? { worktreePath: seedContext.worktreePath }1711 ? { worktreePath: seedContext.worktreePath }
...@@ -1552,7 +1713,47 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1552,7 +1713,47 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1552 envMode: seedContext.envMode,1713 envMode: seedContext.envMode,
1553 });1714 });
1554 },1715 },
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],
1556 );1757 );
15571758
1558 const attemptArchiveThread = useCallback(1759 const attemptArchiveThread = useCallback(
...@@ -1623,6 +1824,88 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1623,6 +1824,88 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1623 [],1824 [],
1624 );1825 );
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
1626 const handleThreadContextMenu = useCallback(1909 const handleThreadContextMenu = useCallback(
1627 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {1910 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
1628 const api = readLocalApi();1911 const api = readLocalApi();
...@@ -1630,7 +1913,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1630,7 +1913,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1630 const threadKey = scopedThreadKey(threadRef);1913 const threadKey = scopedThreadKey(threadRef);
1631 const thread = sidebarThreadByKeyRef.current.get(threadKey) ?? null;1914 const thread = sidebarThreadByKeyRef.current.get(threadKey) ?? null;
1632 if (!thread) return;1915 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;
1634 const clicked = await api.contextMenu.show(1920 const clicked = await api.contextMenu.show(
1635 [1921 [
1636 { id: "rename", label: "Rename thread" },1922 { id: "rename", label: "Rename thread" },
...@@ -1689,6 +1975,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1689,6 +1975,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1689 copyThreadIdToClipboard,1975 copyThreadIdToClipboard,
1690 deleteThread,1976 deleteThread,
1691 markThreadUnread,1977 markThreadUnread,
1978 memberProjectByScopedKey,
1692 project.cwd,1979 project.cwd,
1693 ],1980 ],
1694 );1981 );
...@@ -1732,8 +2019,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1732,8 +2019,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1732 />2019 />
1733 )}2020 )}
1734 <ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} />2021 <ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} />
1735 <span className="flex-1 truncate text-xs font-medium text-foreground/90">2022 <span className="flex min-w-0 flex-1 items-center gap-2">
1736 {project.name}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}
1737 </span>2031 </span>
1738 </SidebarMenuButton>2032 </SidebarMenuButton>
1739 {/* Environment badge – visible by default, crossfades with the2033 {/* Environment badge – visible by default, crossfades with the
...@@ -1766,7 +2060,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1766,7 +2060,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1766 <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">2060 <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">
1767 <button2061 <button
1768 type="button"2062 type="button"
1769 aria-label={`Create new thread in ${project.name}`}2063 aria-label={`Create new thread in ${project.displayName}`}
1770 data-testid="new-thread-button"2064 data-testid="new-thread-button"
1771 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"2065 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"
1772 onClick={handleCreateThreadClick}2066 onClick={handleCreateThreadClick}
...@@ -1817,6 +2111,124 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1817,6 +2111,124 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1817 expandThreadListForProject={expandThreadListForProject}2111 expandThreadListForProject={expandThreadListForProject}
1818 collapseThreadListForProject={collapseThreadListForProject}2112 collapseThreadListForProject={collapseThreadListForProject}
1819 />2113 />
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>
1820 </>2232 </>
1821 );2233 );
1822});2234});
...@@ -1853,13 +2265,17 @@ type SortableProjectHandleProps = Pick<...@@ -1853,13 +2265,17 @@ type SortableProjectHandleProps = Pick<
1853function ProjectSortMenu({2265function ProjectSortMenu({
1854 projectSortOrder,2266 projectSortOrder,
1855 threadSortOrder,2267 threadSortOrder,
2268 projectGroupingMode,
1856 onProjectSortOrderChange,2269 onProjectSortOrderChange,
1857 onThreadSortOrderChange,2270 onThreadSortOrderChange,
2271 onProjectGroupingModeChange,
1858}: {2272}: {
1859 projectSortOrder: SidebarProjectSortOrder;2273 projectSortOrder: SidebarProjectSortOrder;
1860 threadSortOrder: SidebarThreadSortOrder;2274 threadSortOrder: SidebarThreadSortOrder;
2275 projectGroupingMode: SidebarProjectGroupingMode;
1861 onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void;2276 onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void;
1862 onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;2277 onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
2278 onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;
1863}) {2279}) {
1864 return (2280 return (
1865 <Menu>2281 <Menu>
...@@ -1912,6 +2328,30 @@ function ProjectSortMenu({...@@ -1912,6 +2328,30 @@ function ProjectSortMenu({
1912 ))}2328 ))}
1913 </MenuRadioGroup>2329 </MenuRadioGroup>
1914 </MenuGroup>2330 </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>
1915 </MenuPopup>2355 </MenuPopup>
1916 </Menu>2356 </Menu>
1917 );2357 );
...@@ -2029,6 +2469,7 @@ interface SidebarProjectsContentProps {...@@ -2029,6 +2469,7 @@ interface SidebarProjectsContentProps {
2029 handleDesktopUpdateButtonClick: () => void;2469 handleDesktopUpdateButtonClick: () => void;
2030 projectSortOrder: SidebarProjectSortOrder;2470 projectSortOrder: SidebarProjectSortOrder;
2031 threadSortOrder: SidebarThreadSortOrder;2471 threadSortOrder: SidebarThreadSortOrder;
2472 projectGroupingMode: SidebarProjectGroupingMode;
2032 updateSettings: ReturnType<typeof useUpdateSettings>["updateSettings"];2473 updateSettings: ReturnType<typeof useUpdateSettings>["updateSettings"];
2033 openAddProject: () => void;2474 openAddProject: () => void;
2034 isManualProjectSorting: boolean;2475 isManualProjectSorting: boolean;
...@@ -2068,6 +2509,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2068,6 +2509,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2068 handleDesktopUpdateButtonClick,2509 handleDesktopUpdateButtonClick,
2069 projectSortOrder,2510 projectSortOrder,
2070 threadSortOrder,2511 threadSortOrder,
2512 projectGroupingMode,
2071 updateSettings,2513 updateSettings,
2072 openAddProject,2514 openAddProject,
2073 isManualProjectSorting,2515 isManualProjectSorting,
...@@ -2108,6 +2550,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2108,6 +2550,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2108 },2550 },
2109 [updateSettings],2551 [updateSettings],
2110 );2552 );
2553 const handleProjectGroupingModeChange = useCallback(
2554 (groupingMode: SidebarProjectGroupingMode) => {
2555 updateSettings({ sidebarProjectGroupingMode: groupingMode });
2556 },
2557 [updateSettings],
2558 );
21112559
2112 return (2560 return (
2113 <SidebarContent className="gap-0">2561 <SidebarContent className="gap-0">
...@@ -2166,8 +2614,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2166,8 +2614,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2166 <ProjectSortMenu2614 <ProjectSortMenu
2167 projectSortOrder={projectSortOrder}2615 projectSortOrder={projectSortOrder}
2168 threadSortOrder={threadSortOrder}2616 threadSortOrder={threadSortOrder}
2617 projectGroupingMode={projectGroupingMode}
2169 onProjectSortOrderChange={handleProjectSortOrderChange}2618 onProjectSortOrderChange={handleProjectSortOrderChange}
2170 onThreadSortOrderChange={handleThreadSortOrderChange}2619 onThreadSortOrderChange={handleThreadSortOrderChange}
2620 onProjectGroupingModeChange={handleProjectGroupingModeChange}
2171 />2621 />
2172 <Tooltip>2622 <Tooltip>
2173 <TooltipTrigger2623 <TooltipTrigger
...@@ -2282,6 +2732,11 @@ export default function Sidebar() {...@@ -2282,6 +2732,11 @@ export default function Sidebar() {
2282 const isOnSettings = pathname.startsWith("/settings");2732 const isOnSettings = pathname.startsWith("/settings");
2283 const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder);2733 const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder);
2284 const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder);2734 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 }));
2285 const { updateSettings } = useUpdateSettings();2740 const { updateSettings } = useUpdateSettings();
2286 const { handleNewThread } = useNewThreadHandler();2741 const { handleNewThread } = useNewThreadHandler();
2287 const { archiveThread, deleteThread } = useThreadActions();2742 const { archiveThread, deleteThread } = useThreadActions();
...@@ -2319,79 +2774,36 @@ export default function Sidebar() {...@@ -2319,79 +2774,36 @@ export default function Sidebar() {
2319 // cross-environment grouping. Projects that share a repositoryIdentity2774 // cross-environment grouping. Projects that share a repositoryIdentity
2320 // canonicalKey are treated as one logical project in the sidebar.2775 // canonicalKey are treated as one logical project in the sidebar.
2321 const physicalToLogicalKey = useMemo(() => {2776 const physicalToLogicalKey = useMemo(() => {
2322 const mapping = new Map<string, string>();2777 return buildPhysicalToLogicalProjectKeyMap({
2323 for (const project of orderedProjects) {2778 projects: orderedProjects,
2324 const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id));2779 settings: projectGroupingSettings,
2325 mapping.set(physicalKey, deriveLogicalProjectKey(project));2780 });
2326 }2781 }, [orderedProjects, projectGroupingSettings]);
2327 return mapping;2782 const projectPhysicalKeyByScopedRef = useMemo(
2328 }, [orderedProjects]);2783 () =>
2784 new Map(
2785 orderedProjects.map((project) => [
2786 scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
2787 derivePhysicalProjectKey(project),
2788 ]),
2789 ),
2790 [orderedProjects],
2791 );
23292792
2330 const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(() => {2793 const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(() => {
2331 // Group projects by logical key while preserving insertion order from2794 return buildSidebarProjectSnapshots({
2332 // orderedProjects.2795 projects: orderedProjects,
2333 const groupedMembers = new Map<string, Project[]>();2796 settings: projectGroupingSettings,
2334 for (const project of orderedProjects) {2797 primaryEnvironmentId,
2335 const logicalKey = deriveLogicalProjectKey(project);2798 resolveEnvironmentLabel: (environmentId) => {
2336 const existing = groupedMembers.get(logicalKey);2799 const rt = savedEnvironmentRuntimeById[environmentId];
2337 if (existing) {2800 const saved = savedEnvironmentRegistry[environmentId];
2338 existing.push(project);2801 return rt?.descriptor?.label ?? saved?.label ?? null;
2339 } else {2802 },
2340 groupedMembers.set(logicalKey, [project]);2803 });
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;
2393 }, [2804 }, [
2394 orderedProjects,2805 orderedProjects,
2806 projectGroupingSettings,
2395 primaryEnvironmentId,2807 primaryEnvironmentId,
2396 savedEnvironmentRegistry,2808 savedEnvironmentRegistry,
2397 savedEnvironmentRuntimeById,2809 savedEnvironmentRuntimeById,
...@@ -2419,18 +2831,22 @@ export default function Sidebar() {...@@ -2419,18 +2831,22 @@ export default function Sidebar() {
2419 }2831 }
2420 const activeThread = sidebarThreadByKey.get(routeThreadKey);2832 const activeThread = sidebarThreadByKey.get(routeThreadKey);
2421 if (!activeThread) return null;2833 if (!activeThread) return null;
2422 const physicalKey = scopedProjectKey(2834 const physicalKey =
2423 scopeProjectRef(activeThread.environmentId, activeThread.projectId),2835 projectPhysicalKeyByScopedRef.get(
2424 );2836 scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)),
2837 ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId));
2425 return physicalToLogicalKey.get(physicalKey) ?? physicalKey;2838 return physicalToLogicalKey.get(physicalKey) ?? physicalKey;
2426 }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]);2839 }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]);
24272840
2428 // Group threads by logical project key so all threads from grouped projects2841 // Group threads by logical project key so all threads from grouped projects
2429 // are displayed together.2842 // are displayed together.
2430 const threadsByProjectKey = useMemo(() => {2843 const threadsByProjectKey = useMemo(() => {
2431 const next = new Map<string, SidebarThreadSummary[]>();2844 const next = new Map<string, SidebarThreadSummary[]>();
2432 for (const thread of sidebarThreads) {2845 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));
2434 const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey;2850 const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey;
2435 const existing = next.get(logicalKey);2851 const existing = next.get(logicalKey);
2436 if (existing) {2852 if (existing) {
...@@ -2440,7 +2856,7 @@ export default function Sidebar() {...@@ -2440,7 +2856,7 @@ export default function Sidebar() {
2440 }2856 }
2441 }2857 }
2442 return next;2858 return next;
2443 }, [sidebarThreads, physicalToLogicalKey]);2859 }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]);
2444 const getCurrentSidebarShortcutContext = useCallback(2860 const getCurrentSidebarShortcutContext = useCallback(
2445 () => ({2861 () => ({
2446 terminalFocus: isTerminalFocused(),2862 terminalFocus: isTerminalFocused(),
...@@ -2507,8 +2923,10 @@ export default function Sidebar() {...@@ -2507,8 +2923,10 @@ export default function Sidebar() {
2507 const activeProject = sidebarProjects.find((project) => project.projectKey === active.id);2923 const activeProject = sidebarProjects.find((project) => project.projectKey === active.id);
2508 const overProject = sidebarProjects.find((project) => project.projectKey === over.id);2924 const overProject = sidebarProjects.find((project) => project.projectKey === over.id);
2509 if (!activeProject || !overProject) return;2925 if (!activeProject || !overProject) return;
2510 const activeMemberKeys = activeProject.memberProjectRefs.map(scopedProjectKey);2926 const activeMemberKeys = activeProject.memberProjects.map(
2511 const overMemberKeys = overProject.memberProjectRefs.map(scopedProjectKey);2927 (member) => member.physicalProjectKey,
2928 );
2929 const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey);
2512 reorderProjects(activeMemberKeys, overMemberKeys);2930 reorderProjects(activeMemberKeys, overMemberKeys);
2513 },2931 },
2514 [sidebarProjectSortOrder, reorderProjects, sidebarProjects],2932 [sidebarProjectSortOrder, reorderProjects, sidebarProjects],
...@@ -2557,7 +2975,10 @@ export default function Sidebar() {...@@ -2557,7 +2975,10 @@ export default function Sidebar() {
2557 id: project.projectKey,2975 id: project.projectKey,
2558 }));2976 }));
2559 const sortableThreads = visibleThreads.map((thread) => {2977 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));
2561 return {2982 return {
2562 ...thread,2983 ...thread,
2563 projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId,2984 projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId,
...@@ -2574,6 +2995,7 @@ export default function Sidebar() {...@@ -2574,6 +2995,7 @@ export default function Sidebar() {
2574 }, [2995 }, [
2575 sidebarProjectSortOrder,2996 sidebarProjectSortOrder,
2576 physicalToLogicalKey,2997 physicalToLogicalKey,
2998 projectPhysicalKeyByScopedRef,
2577 sidebarProjectByKey,2999 sidebarProjectByKey,
2578 sidebarProjects,3000 sidebarProjects,
2579 visibleThreads,3001 visibleThreads,
...@@ -2978,6 +3400,7 @@ export default function Sidebar() {...@@ -2978,6 +3400,7 @@ export default function Sidebar() {
2978 handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick}3400 handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick}
2979 projectSortOrder={sidebarProjectSortOrder}3401 projectSortOrder={sidebarProjectSortOrder}
2980 threadSortOrder={sidebarThreadSortOrder}3402 threadSortOrder={sidebarThreadSortOrder}
3403 projectGroupingMode={sidebarProjectGroupingMode}
2981 updateSettings={updateSettings}3404 updateSettings={updateSettings}
2982 openAddProject={openAddProjectCommandPalette}3405 openAddProject={openAddProjectCommandPalette}
2983 isManualProjectSorting={isManualProjectSorting}3406 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 @@...@@ -1,9 +1,22 @@
1import type { ContextMenuItem } from "@t3tools/contracts";1import 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
3/**17/**
4 * Imperative DOM-based context menu for non-Electron environments.18 * Imperative DOM-based context menu for non-Electron environments.
5 * Shows a positioned dropdown and returns a promise that resolves19 * Supports nested submenus and resolves with the clicked leaf item id.
6 * with the clicked item id, or null if dismissed.
7 */20 */
8export function showContextMenuFallback<T extends string>(21export function showContextMenuFallback<T extends string>(
9 items: readonly ContextMenuItem<T>[],22 items: readonly ContextMenuItem<T>[],
...@@ -13,62 +26,117 @@ export function showContextMenuFallback<T extends string>(...@@ -13,62 +26,117 @@ export function showContextMenuFallback<T extends string>(
13 const overlay = document.createElement("div");26 const overlay = document.createElement("div");
14 overlay.style.cssText = "position:fixed;inset:0;z-index:9999";27 overlay.style.cssText = "position:fixed;inset:0;z-index:9999";
1528
16 const menu = document.createElement("div");29 const menuStack: HTMLDivElement[] = [];
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`;
2430
25 function cleanup(result: T | null) {31 const cleanup = (result: T | null) => {
26 document.removeEventListener("keydown", onKeyDown);32 document.removeEventListener("keydown", onKeyDown);
27 overlay.remove();33 overlay.remove();
28 menu.remove();34 for (const menu of menuStack) {
35 menu.remove();
36 }
29 resolve(result);37 resolve(result);
30 }38 };
3139
32 function onKeyDown(e: KeyboardEvent) {40 const onKeyDown = (event: KeyboardEvent) => {
33 if (e.key === "Escape") {41 if (event.key === "Escape") {
34 e.preventDefault();42 event.preventDefault();
35 cleanup(null);43 cleanup(null);
36 }44 }
37 }45 };
3846
39 overlay.addEventListener("mousedown", () => cleanup(null));47 const closeMenusFromLevel = (level: number) => {
40 document.addEventListener("keydown", onKeyDown);48 while (menuStack.length > level) {
4149 menuStack.pop()?.remove();
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));
56 }50 }
57 menu.appendChild(btn);51 };
58 }
5952
60 document.body.appendChild(overlay);53 const openMenu = (
61 document.body.appendChild(menu);54 entries: readonly ContextMenuItem<T>[],
55 preferredLeft: number,
56 preferredTop: number,
57 level: number,
58 ) => {
59 closeMenusFromLevel(level);
6260
63 // Adjust if menu overflows viewport61 const menu = document.createElement("div");
64 requestAnimationFrame(() => {62 menu.className =
65 const rect = menu.getBoundingClientRect();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";
66 if (rect.right > window.innerWidth) {64 menu.style.left = `${preferredLeft}px`;
67 menu.style.left = `${window.innerWidth - rect.width - 4}px`;65 menu.style.top = `${preferredTop}px`;
68 }66 menu.dataset.level = String(level);
69 if (rect.bottom > window.innerHeight) {67
70 menu.style.top = `${window.innerHeight - rect.height - 4}px`;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);
71 }123 }
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);
73 });141 });
74}142}
apps/web/src/environmentGrouping.test.ts+165-4
...@@ -10,7 +10,12 @@ import {...@@ -10,7 +10,12 @@ import {
10 type AppState,10 type AppState,
11 type EnvironmentState,11 type EnvironmentState,
12} from "./store";12} from "./store";
13import { deriveLogicalProjectKey } from "./logicalProject";13import {
14 deriveLogicalProjectKey,
15 deriveLogicalProjectKeyFromSettings,
16 derivePhysicalProjectKey,
17 resolveProjectGroupingMode,
18} from "./logicalProject";
14import type { Project, SidebarThreadSummary } from "./types";19import type { Project, SidebarThreadSummary } from "./types";
15import { DEFAULT_INTERACTION_MODE } from "./types";20import { DEFAULT_INTERACTION_MODE } from "./types";
1621
...@@ -31,6 +36,10 @@ const threadL1 = ThreadId.make("thread-local-only-1");...@@ -31,6 +36,10 @@ const threadL1 = ThreadId.make("thread-local-only-1");
31const threadRO1 = ThreadId.make("thread-remote-only-1");36const threadRO1 = ThreadId.make("thread-remote-only-1");
3237
33const SHARED_REPO_CANONICAL_KEY = "github.com/example/shared-repo";38const SHARED_REPO_CANONICAL_KEY = "github.com/example/shared-repo";
39const DEFAULT_GROUPING_SETTINGS = {
40 sidebarProjectGroupingMode: "repository" as const,
41 sidebarProjectGroupingOverrides: {},
42};
3443
35// ── Factory Helpers ──────────────────────────────────────────────────44// ── Factory Helpers ──────────────────────────────────────────────────
3645
...@@ -238,9 +247,7 @@ describe("environment grouping", () => {...@@ -238,9 +247,7 @@ describe("environment grouping", () => {
238 environmentId: primaryEnvId,247 environmentId: primaryEnvId,
239 name: "local-only",248 name: "local-only",
240 });249 });
241 const key = deriveLogicalProjectKey(project);250 expect(deriveLogicalProjectKey(project)).toBe(derivePhysicalProjectKey(project));
242 expect(key).toContain(primaryEnvId);
243 expect(key).toContain(localOnlyProjectId);
244 });251 });
245252
246 it("groups projects from different environments that share the same canonical key", () => {253 it("groups projects from different environments that share the same canonical key", () => {
...@@ -273,6 +280,134 @@ describe("environment grouping", () => {...@@ -273,6 +280,134 @@ describe("environment grouping", () => {
273 expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote));280 expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote));
274 });281 });
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
276 it("does NOT group projects without shared canonical key", () => {411 it("does NOT group projects without shared canonical key", () => {
277 const local = makeProject({412 const local = makeProject({
278 id: localOnlyProjectId,413 id: localOnlyProjectId,
...@@ -286,6 +421,32 @@ describe("environment grouping", () => {...@@ -286,6 +421,32 @@ describe("environment grouping", () => {
286 });421 });
287 expect(deriveLogicalProjectKey(local)).not.toBe(deriveLogicalProjectKey(remote));422 expect(deriveLogicalProjectKey(local)).not.toBe(deriveLogicalProjectKey(remote));
288 });423 });
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 });
289 });450 });
290451
291 describe("selectProjectsAcrossEnvironments", () => {452 describe("selectProjectsAcrossEnvironments", () => {
apps/web/src/environments/runtime/service.ts+3-4
...@@ -13,9 +13,7 @@ import { Throttler } from "@tanstack/react-pacer";...@@ -13,9 +13,7 @@ import { Throttler } from "@tanstack/react-pacer";
13import {13import {
14 createKnownEnvironment,14 createKnownEnvironment,
15 getKnownEnvironmentWsBaseUrl,15 getKnownEnvironmentWsBaseUrl,
16 scopedProjectKey,
17 scopedThreadKey,16 scopedThreadKey,
18 scopeProjectRef,
19 scopeThreadRef,17 scopeThreadRef,
20} from "@t3tools/client-runtime";18} from "@t3tools/client-runtime";
2119
...@@ -62,6 +60,7 @@ import { useTerminalStateStore } from "~/terminalStateStore";...@@ -62,6 +60,7 @@ import { useTerminalStateStore } from "~/terminalStateStore";
62import { useUiStateStore } from "~/uiStateStore";60import { useUiStateStore } from "~/uiStateStore";
63import { WsTransport } from "../../rpc/wsTransport";61import { WsTransport } from "../../rpc/wsTransport";
64import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient";62import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient";
63import { derivePhysicalProjectKey } from "../../logicalProject";
6564
66type EnvironmentServiceState = {65type EnvironmentServiceState = {
67 readonly queryClient: QueryClient;66 readonly queryClient: QueryClient;
...@@ -470,7 +469,7 @@ function syncProjectUiFromStore() {...@@ -470,7 +469,7 @@ function syncProjectUiFromStore() {
470 const projects = selectProjectsAcrossEnvironments(useStore.getState());469 const projects = selectProjectsAcrossEnvironments(useStore.getState());
471 useUiStateStore.getState().syncProjects(470 useUiStateStore.getState().syncProjects(
472 projects.map((project) => ({471 projects.map((project) => ({
473 key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),472 key: derivePhysicalProjectKey(project),
474 cwd: project.cwd,473 cwd: project.cwd,
475 })),474 })),
476 );475 );
...@@ -543,7 +542,7 @@ function applyRecoveredEventBatch(...@@ -543,7 +542,7 @@ function applyRecoveredEventBatch(
543 const projects = selectProjectsAcrossEnvironments(useStore.getState());542 const projects = selectProjectsAcrossEnvironments(useStore.getState());
544 useUiStateStore.getState().syncProjects(543 useUiStateStore.getState().syncProjects(
545 projects.map((project) => ({544 projects.map((project) => ({
546 key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),545 key: derivePhysicalProjectKey(project),
547 cwd: project.cwd,546 cwd: project.cwd,
548 })),547 })),
549 );548 );
apps/web/src/hooks/useHandleNewThread.ts+8-3
...@@ -10,14 +10,19 @@ import {...@@ -10,14 +10,19 @@ import {
10} from "../composerDraftStore";10} from "../composerDraftStore";
11import { newDraftId, newThreadId } from "../lib/utils";11import { newDraftId, newThreadId } from "../lib/utils";
12import { orderItemsByPreferredIds } from "../components/Sidebar.logic";12import { orderItemsByPreferredIds } from "../components/Sidebar.logic";
13import { deriveLogicalProjectKey } from "../logicalProject";13import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
14import { selectProjectsAcrossEnvironments, useStore } from "../store";14import { selectProjectsAcrossEnvironments, useStore } from "../store";
15import { createThreadSelectorByRef } from "../storeSelectors";15import { createThreadSelectorByRef } from "../storeSelectors";
16import { resolveThreadRouteTarget } from "../threadRoutes";16import { resolveThreadRouteTarget } from "../threadRoutes";
17import { useUiStateStore } from "../uiStateStore";17import { useUiStateStore } from "../uiStateStore";
18import { useSettings } from "./useSettings";
1819
19function useNewThreadState() {20function useNewThreadState() {
20 const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store)));21 const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store)));
22 const projectGroupingSettings = useSettings((settings) => ({
23 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
24 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
25 }));
21 const router = useRouter();26 const router = useRouter();
22 const getCurrentRouteTarget = useCallback(() => {27 const getCurrentRouteTarget = useCallback(() => {
23 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};28 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
...@@ -48,7 +53,7 @@ function useNewThreadState() {...@@ -48,7 +53,7 @@ function useNewThreadState() {
48 candidate.environmentId === projectRef.environmentId,53 candidate.environmentId === projectRef.environmentId,
49 );54 );
50 const logicalProjectKey = project55 const logicalProjectKey = project
51 ? deriveLogicalProjectKey(project)56 ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)
52 : scopedProjectKey(projectRef);57 : scopedProjectKey(projectRef);
53 const hasBranchOption = options?.branch !== undefined;58 const hasBranchOption = options?.branch !== undefined;
54 const hasWorktreePathOption = options?.worktreePath !== undefined;59 const hasWorktreePathOption = options?.worktreePath !== undefined;
...@@ -129,7 +134,7 @@ function useNewThreadState() {...@@ -129,7 +134,7 @@ function useNewThreadState() {
129 });134 });
130 })();135 })();
131 },136 },
132 [getCurrentRouteTarget, router, projects],137 [getCurrentRouteTarget, projectGroupingSettings, router, projects],
133 );138 );
134}139}
135140
apps/web/src/localApi.test.ts+25-34
...@@ -528,13 +528,20 @@ describe("wsApi", () => {...@@ -528,13 +528,20 @@ describe("wsApi", () => {
528 });528 });
529529
530 it("reads and writes persistence through the desktop bridge when available", async () => {530 it("reads and writes persistence through the desktop bridge when available", async () => {
531 const getClientSettings = vi.fn().mockResolvedValue({531 const clientSettings = {
532 confirmThreadArchive: true,532 confirmThreadArchive: true,
533 confirmThreadDelete: false,533 confirmThreadDelete: false,
534 diffWordWrap: true,534 diffWordWrap: true,
535 sidebarProjectSortOrder: "manual",535 sidebarProjectGroupingMode: "repository_path" as const,
536 sidebarThreadSortOrder: "created_at",536 sidebarProjectGroupingOverrides: {
537 timestampFormat: "24-hour",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,
538 });545 });
539 const setClientSettings = vi.fn().mockResolvedValue(undefined);546 const setClientSettings = vi.fn().mockResolvedValue(undefined);
540 const getSavedEnvironmentRegistry = vi.fn().mockResolvedValue([]);547 const getSavedEnvironmentRegistry = vi.fn().mockResolvedValue([]);
...@@ -556,14 +563,7 @@ describe("wsApi", () => {...@@ -556,14 +563,7 @@ describe("wsApi", () => {
556 const api = createLocalApi(rpcClientMock as never);563 const api = createLocalApi(rpcClientMock as never);
557564
558 await api.persistence.getClientSettings();565 await api.persistence.getClientSettings();
559 await api.persistence.setClientSettings({566 await api.persistence.setClientSettings(clientSettings);
560 confirmThreadArchive: true,
561 confirmThreadDelete: false,
562 diffWordWrap: true,
563 sidebarProjectSortOrder: "manual",
564 sidebarThreadSortOrder: "created_at",
565 timestampFormat: "24-hour",
566 });
567 await api.persistence.getSavedEnvironmentRegistry();567 await api.persistence.getSavedEnvironmentRegistry();
568 await api.persistence.setSavedEnvironmentRegistry([]);568 await api.persistence.setSavedEnvironmentRegistry([]);
569 await api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local"));569 await api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local"));
...@@ -574,14 +574,7 @@ describe("wsApi", () => {...@@ -574,14 +574,7 @@ describe("wsApi", () => {
574 await api.persistence.removeSavedEnvironmentSecret(EnvironmentId.make("environment-local"));574 await api.persistence.removeSavedEnvironmentSecret(EnvironmentId.make("environment-local"));
575575
576 expect(getClientSettings).toHaveBeenCalledWith();576 expect(getClientSettings).toHaveBeenCalledWith();
577 expect(setClientSettings).toHaveBeenCalledWith({577 expect(setClientSettings).toHaveBeenCalledWith(clientSettings);
578 confirmThreadArchive: true,
579 confirmThreadDelete: false,
580 diffWordWrap: true,
581 sidebarProjectSortOrder: "manual",
582 sidebarThreadSortOrder: "created_at",
583 timestampFormat: "24-hour",
584 });
585 expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith();578 expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith();
586 expect(setSavedEnvironmentRegistry).toHaveBeenCalledWith([]);579 expect(setSavedEnvironmentRegistry).toHaveBeenCalledWith([]);
587 expect(getSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local");580 expect(getSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local");
...@@ -592,15 +585,20 @@ describe("wsApi", () => {...@@ -592,15 +585,20 @@ describe("wsApi", () => {
592 it("falls back to browser storage for persistence when the desktop bridge is missing", async () => {585 it("falls back to browser storage for persistence when the desktop bridge is missing", async () => {
593 const { createLocalApi } = await import("./localApi");586 const { createLocalApi } = await import("./localApi");
594 const api = createLocalApi(rpcClientMock as never);587 const api = createLocalApi(rpcClientMock as never);
595588 const clientSettings = {
596 await api.persistence.setClientSettings({
597 confirmThreadArchive: true,589 confirmThreadArchive: true,
598 confirmThreadDelete: false,590 confirmThreadDelete: false,
599 diffWordWrap: true,591 diffWordWrap: true,
600 sidebarProjectSortOrder: "manual",592 sidebarProjectGroupingMode: "repository_path" as const,
601 sidebarThreadSortOrder: "created_at",593 sidebarProjectGroupingOverrides: {
602 timestampFormat: "24-hour",594 "environment-local:/tmp/project": "separate" as const,
603 });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);
604 await api.persistence.setSavedEnvironmentRegistry([602 await api.persistence.setSavedEnvironmentRegistry([
605 {603 {
606 environmentId: EnvironmentId.make("environment-local"),604 environmentId: EnvironmentId.make("environment-local"),
...@@ -616,14 +614,7 @@ describe("wsApi", () => {...@@ -616,14 +614,7 @@ describe("wsApi", () => {
616 "bearer-token",614 "bearer-token",
617 );615 );
618616
619 await expect(api.persistence.getClientSettings()).resolves.toEqual({617 await expect(api.persistence.getClientSettings()).resolves.toEqual(clientSettings);
620 confirmThreadArchive: true,
621 confirmThreadDelete: false,
622 diffWordWrap: true,
623 sidebarProjectSortOrder: "manual",
624 sidebarThreadSortOrder: "created_at",
625 timestampFormat: "24-hour",
626 });
627 await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([618 await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([
628 {619 {
629 environmentId: EnvironmentId.make("environment-local"),620 environmentId: EnvironmentId.make("environment-local"),
apps/web/src/logicalProject.ts+143-5
...@@ -1,19 +1,157 @@...@@ -1,19 +1,157 @@
1import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime";1import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime";
2import type { ScopedProjectRef } from "@t3tools/contracts";2import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
3import { normalizeProjectPathForComparison } from "./lib/projectPaths";
3import type { Project } from "./types";4import 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
5export function deriveLogicalProjectKey(101export function deriveLogicalProjectKey(
6 project: Pick<Project, "environmentId" | "id" | "repositoryIdentity">,102 project: Pick<Project, "environmentId" | "id" | "cwd" | "repositoryIdentity">,
103 options?: {
104 groupingMode?: SidebarProjectGroupingMode;
105 },
7): string {106): string {
107 const groupingMode = options?.groupingMode ?? "repository";
108 if (groupingMode === "separate") {
109 return derivePhysicalProjectKey(project);
110 }
111
8 return (112 return (
9 project.repositoryIdentity?.canonicalKey ??113 deriveRepositoryScopedKey(project, groupingMode) ??
114 derivePhysicalProjectKey(project) ??
10 scopedProjectKey(scopeProjectRef(project.environmentId, project.id))115 scopedProjectKey(scopeProjectRef(project.environmentId, project.id))
11 );116 );
12}117}
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
14export function deriveLogicalProjectKeyFromRef(128export function deriveLogicalProjectKeyFromRef(
15 projectRef: ScopedProjectRef,129 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 },
17): string {134): 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;
19}157}
apps/web/src/routes/__root.tsx+23-7
...@@ -22,6 +22,11 @@ import { Button } from "../components/ui/button";...@@ -22,6 +22,11 @@ import { Button } from "../components/ui/button";
22import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";22import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
23import { resolveAndPersistPreferredEditor } from "../editorPreferences";23import { resolveAndPersistPreferredEditor } from "../editorPreferences";
24import { readLocalApi } from "../localApi";24import { readLocalApi } from "../localApi";
25import { useSettings } from "../hooks/useSettings";
26import {
27 deriveLogicalProjectKeyFromSettings,
28 derivePhysicalProjectKeyFromPath,
29} from "../logicalProject";
25import {30import {
26 getServerConfigUpdatedNotification,31 getServerConfigUpdatedNotification,
27 ServerConfigUpdatedNotification,32 ServerConfigUpdatedNotification,
...@@ -204,6 +209,10 @@ function EventRouter() {...@@ -204,6 +209,10 @@ function EventRouter() {
204 const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId);209 const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId);
205 const navigate = useNavigate();210 const navigate = useNavigate();
206 const pathname = useLocation({ select: (loc) => loc.pathname });211 const pathname = useLocation({ select: (loc) => loc.pathname });
212 const projectGroupingSettings = useSettings((settings) => ({
213 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
214 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
215 }));
207 const readPathname = useEffectEvent(() => pathname);216 const readPathname = useEffectEvent(() => pathname);
208 const handledBootstrapThreadIdRef = useRef<string | null>(null);217 const handledBootstrapThreadIdRef = useRef<string | null>(null);
209 const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0);218 const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0);
...@@ -224,14 +233,21 @@ function EventRouter() {...@@ -224,14 +233,21 @@ function EventRouter() {
224 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {233 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
225 return;234 return;
226 }235 }
227 useUiStateStore236 const bootstrapEnvironmentState =
228 .getState()237 useStore.getState().environmentStateById[payload.environment.environmentId];
229 .setProjectExpanded(238 const bootstrapProject =
230 scopedProjectKey(239 bootstrapEnvironmentState?.projectById[payload.bootstrapProjectId] ?? null;
231 scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId),240 const bootstrapProjectKey =
232 ),241 (bootstrapProject
233 true,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),
234 );249 );
250 useUiStateStore.getState().setProjectExpanded(bootstrapProjectKey, true);
235251
236 if (readPathname() !== "/") {252 if (readPathname() !== "/") {
237 return;253 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;...@@ -51,6 +51,7 @@ export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type;
51export const RepositoryIdentity = Schema.Struct({51export const RepositoryIdentity = Schema.Struct({
52 canonicalKey: TrimmedNonEmptyString,52 canonicalKey: TrimmedNonEmptyString,
53 locator: RepositoryIdentityLocator,53 locator: RepositoryIdentityLocator,
54 rootPath: Schema.optionalKey(TrimmedNonEmptyString),
54 displayName: Schema.optionalKey(TrimmedNonEmptyString),55 displayName: Schema.optionalKey(TrimmedNonEmptyString),
55 provider: Schema.optionalKey(TrimmedNonEmptyString),56 provider: Schema.optionalKey(TrimmedNonEmptyString),
56 owner: Schema.optionalKey(TrimmedNonEmptyString),57 owner: Schema.optionalKey(TrimmedNonEmptyString),
packages/contracts/src/ipc.ts+1
...@@ -60,6 +60,7 @@ export interface ContextMenuItem<T extends string = string> {...@@ -60,6 +60,7 @@ export interface ContextMenuItem<T extends string = string> {
60 label: string;60 label: string;
61 destructive?: boolean;61 destructive?: boolean;
62 disabled?: boolean;62 disabled?: boolean;
63 children?: readonly ContextMenuItem<T>[];
63}64}
6465
65export type DesktopUpdateStatus =66export type DesktopUpdateStatus =
packages/contracts/src/settings.ts+15
...@@ -23,10 +23,25 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at...@@ -23,10 +23,25 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at
23export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;23export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;
24export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";24export 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
26export const ClientSettingsSchema = Schema.Struct({34export const ClientSettingsSchema = Schema.Struct({
27 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),35 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
28 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),36 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
29 diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),37 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({}))),
30 sidebarProjectSortOrder: SidebarProjectSortOrder.pipe(45 sidebarProjectSortOrder: SidebarProjectSortOrder.pipe(
31 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)),46 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)),
32 ),47 ),