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