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