From b6c45975ff6dab93b1d4d7e32c46c543244b7454 Mon Sep 17 00:00:00 2001 From: tlh38 Date: Thu, 16 Apr 2026 00:09:59 -0400 Subject: [PATCH 1/5] Scope logical project keys by repository root - Preserve git top-level root paths in repository identity - Distinguish nested workspaces within the same repo when deriving project grouping keys - Add coverage for nested-root resolution and cross-environment grouping --- .../Layers/RepositoryIdentityResolver.test.ts | 26 +++++++ .../Layers/RepositoryIdentityResolver.ts | 4 +- apps/web/src/environmentGrouping.test.ts | 72 +++++++++++++++++++ apps/web/src/logicalProject.ts | 54 ++++++++++++-- packages/contracts/src/environment.ts | 1 + 5 files changed, 152 insertions(+), 5 deletions(-) diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 57f4464804d..4cba7f50644 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -10,6 +10,8 @@ import { RepositoryIdentityResolverLive, } from "./RepositoryIdentityResolver.ts"; +const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); + const git = (cwd: string, args: ReadonlyArray) => Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); @@ -41,6 +43,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(normalizePathSeparators(identity?.rootPath ?? "")).toBe(normalizePathSeparators(cwd)); expect(identity?.displayName).toBe("t3tools/t3code"); expect(identity?.provider).toBe("github"); expect(identity?.owner).toBe("t3tools"); @@ -48,6 +51,29 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); + it.effect("returns the git top-level root path when resolving from a nested workspace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const repoRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-nested-root-test-", + }); + const nestedWorkspace = `${repoRoot}/packages/web`; + + yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true }); + yield* git(repoRoot, ["init"]); + yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(nestedWorkspace); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(normalizePathSeparators(identity?.rootPath ?? "")).toBe( + normalizePathSeparators(repoRoot), + ); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + it.effect("returns null for non-git folders and repos without remotes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index 531737ec66c..e439fa19a66 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -42,6 +42,7 @@ function pickPrimaryRemote( function buildRepositoryIdentity(input: { readonly remoteName: string; readonly remoteUrl: string; + readonly rootPath: string; }): RepositoryIdentity { const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl); @@ -57,6 +58,7 @@ function buildRepositoryIdentity(input: { remoteName: input.remoteName, remoteUrl: input.remoteUrl, }, + rootPath: input.rootPath, ...(repositoryPath ? { displayName: repositoryPath } : {}), ...(hostingProvider ? { provider: hostingProvider.kind } : {}), ...(owner ? { owner } : {}), @@ -108,7 +110,7 @@ async function resolveRepositoryIdentityFromCacheKey( } const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); - return remote ? buildRepositoryIdentity(remote) : null; + return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; } catch { return null; } diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 4e8473188a1..570c18ff7f4 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -273,6 +273,78 @@ describe("environment grouping", () => { expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote)); }); + it("does not group repo root and nested projects from the same repository", () => { + const rootProject = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "shared-repo", + cwd: "/workspace/repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + const nestedProject = makeProject({ + id: localOnlyProjectId, + environmentId: primaryEnvId, + name: "web", + cwd: "/workspace/repo/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect(deriveLogicalProjectKey(rootProject)).toBe(SHARED_REPO_CANONICAL_KEY); + expect(deriveLogicalProjectKey(nestedProject)).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); + }); + + it("groups matching nested project paths across environments when repo roots differ", () => { + const primary = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "web", + cwd: "/workspace/repo/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + const remote = makeProject({ + id: sharedProjectRemoteId, + environmentId: remoteEnvId, + name: "web", + cwd: "/srv/checkout/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/srv/checkout", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect(deriveLogicalProjectKey(primary)).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); + expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote)); + }); + it("does NOT group projects without shared canonical key", () => { const local = makeProject({ id: localOnlyProjectId, diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index 789441877bc..2ef8a4f0f37 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -1,19 +1,65 @@ import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; import type { ScopedProjectRef } from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "./lib/projectPaths"; import type { Project } from "./types"; +function deriveRepositoryRelativeProjectPath( + project: Pick, +): string | null { + const rootPath = project.repositoryIdentity?.rootPath?.trim(); + if (!rootPath) { + return null; + } + + const normalizedProjectPath = normalizeProjectPathForComparison(project.cwd); + const normalizedRootPath = normalizeProjectPathForComparison(rootPath); + if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { + return null; + } + + if (normalizedProjectPath === normalizedRootPath) { + return ""; + } + + const separator = normalizedRootPath.includes("\\") ? "\\" : "/"; + const rootPrefix = `${normalizedRootPath}${separator}`; + if (!normalizedProjectPath.startsWith(rootPrefix)) { + return null; + } + + return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/"); +} + +function deriveRepositoryScopedKey( + project: Pick, +): string | null { + const canonicalKey = project.repositoryIdentity?.canonicalKey; + if (!canonicalKey) { + return null; + } + + const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); + if (relativeProjectPath === null) { + return canonicalKey; + } + + return relativeProjectPath.length === 0 + ? canonicalKey + : `${canonicalKey}::${relativeProjectPath}`; +} + export function deriveLogicalProjectKey( - project: Pick, + project: Pick, ): string { return ( - project.repositoryIdentity?.canonicalKey ?? + deriveRepositoryScopedKey(project) ?? scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) ); } export function deriveLogicalProjectKeyFromRef( projectRef: ScopedProjectRef, - project: Pick | null | undefined, + project: Pick | null | undefined, ): string { - return project?.repositoryIdentity?.canonicalKey ?? scopedProjectKey(projectRef); + return (project ? deriveRepositoryScopedKey(project) : null) ?? scopedProjectKey(projectRef); } diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index bc3b5459cd9..28adbf21781 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -51,6 +51,7 @@ export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; export const RepositoryIdentity = Schema.Struct({ canonicalKey: TrimmedNonEmptyString, locator: RepositoryIdentityLocator, + rootPath: Schema.optionalKey(TrimmedNonEmptyString), displayName: Schema.optionalKey(TrimmedNonEmptyString), provider: Schema.optionalKey(TrimmedNonEmptyString), owner: Schema.optionalKey(TrimmedNonEmptyString), From 41e5a908c829d7d8e7fffb6017e7573080fc0ac3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 15 Apr 2026 23:52:21 -0700 Subject: [PATCH 2/5] Handle nested project directories in sidebar - Group projects by repository path with per-project overrides - Add nested context menu actions for grouped project members - Update rename/delete flows for multi-project sidebar rows --- apps/desktop/src/clientPersistence.test.ts | 4 + apps/desktop/src/main.ts | 85 +- apps/web/src/components/ChatView.tsx | 19 +- apps/web/src/components/Sidebar.tsx | 757 +++++++++++++----- .../components/settings/SettingsPanels.tsx | 56 +- apps/web/src/contextMenuFallback.test.ts | 221 +++++ apps/web/src/contextMenuFallback.ts | 162 ++-- apps/web/src/environmentGrouping.test.ts | 105 ++- apps/web/src/environments/runtime/service.ts | 7 +- apps/web/src/hooks/useHandleNewThread.ts | 11 +- apps/web/src/localApi.test.ts | 59 +- apps/web/src/logicalProject.ts | 100 ++- apps/web/src/sidebarProjectGrouping.ts | 118 +++ packages/contracts/src/ipc.ts | 1 + packages/contracts/src/settings.ts | 15 + 15 files changed, 1397 insertions(+), 323 deletions(-) create mode 100644 apps/web/src/contextMenuFallback.test.ts create mode 100644 apps/web/src/sidebarProjectGrouping.ts diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index df2178c0b0d..fa263b18ff1 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -52,6 +52,10 @@ const clientSettings: ClientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + sidebarProjectGroupingMode: "repository_path", + sidebarProjectGroupingOverrides: { + "environment-1:/tmp/project-a": "separate", + }, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 987cad34089..50c4a12fbfb 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -159,6 +159,35 @@ const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linu const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; +function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { + const normalizedItems: ContextMenuItem[] = []; + + for (const sourceItem of source) { + if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { + continue; + } + + const normalizedItem: ContextMenuItem = { + id: sourceItem.id, + label: sourceItem.label, + destructive: sourceItem.destructive === true, + disabled: sourceItem.disabled === true, + }; + + if (sourceItem.children) { + const normalizedChildren = normalizeContextMenuItems(sourceItem.children); + if (normalizedChildren.length === 0) { + continue; + } + normalizedItem.children = normalizedChildren; + } + + normalizedItems.push(normalizedItem); + } + + return normalizedItems; +} + type WindowTitleBarOptions = Pick< BrowserWindowConstructorOptions, "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" @@ -1705,14 +1734,7 @@ function registerIpcHandlers(): void { ipcMain.handle( CONTEXT_MENU_CHANNEL, async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { - const normalizedItems = items - .filter((item) => typeof item.id === "string" && typeof item.label === "string") - .map((item) => ({ - id: item.id, - label: item.label, - destructive: item.destructive === true, - disabled: item.disabled === true, - })); + const normalizedItems = normalizeContextMenuItems(items); if (normalizedItems.length === 0) { return null; } @@ -1733,28 +1755,37 @@ function registerIpcHandlers(): void { if (!window) return null; return new Promise((resolve) => { - const template: MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - for (const item of normalizedItems) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } - const itemOption: MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - click: () => resolve(item.id), - }; - if (item.destructive) { - const destructiveIcon = getDestructiveMenuIcon(); - if (destructiveIcon) { - itemOption.icon = destructiveIcon; + const buildTemplate = ( + entries: readonly ContextMenuItem[], + ): MenuItemConstructorOptions[] => { + const template: MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; + } + const itemOption: MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children); + } else { + itemOption.click = () => resolve(item.id); } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (destructiveIcon) { + itemOption.icon = destructiveIcon; + } + } + template.push(itemOption); } - template.push(itemOption); - } + return template; + }; - const menu = Menu.buildFromTemplate(template); + const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems)); menu.popup({ window, ...popupPosition, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c16aab4fbe7..ce3263e0214 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -111,7 +111,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { deriveLogicalProjectKey } from "../logicalProject"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, @@ -843,10 +843,16 @@ export default function ChatView(props: ChatViewProps) { const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; - const logicalKey = deriveLogicalProjectKey(activeProject); - const memberProjects = allProjects.filter((p) => deriveLogicalProjectKey(p) === logicalKey); + const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); + const memberProjects = allProjects.filter( + (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, + ); const seen = new Set(); const envs: Array<{ environmentId: EnvironmentId; @@ -882,6 +888,7 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, allProjects, + projectGroupingSettings, primaryEnvironmentId, savedEnvironmentRegistry, savedEnvironmentRuntimeById, @@ -911,7 +918,10 @@ export default function ChatView(props: ChatViewProps) { throw new Error("No active project is available for this pull request."); } const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); - const logicalProjectKey = deriveLogicalProjectKey(activeProject); + const logicalProjectKey = deriveLogicalProjectKeyFromSettings( + activeProject, + projectGroupingSettings, + ); const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); if (storedDraftSession) { setDraftThreadContext(storedDraftSession.draftId, input); @@ -972,6 +982,7 @@ export default function ChatView(props: ChatViewProps) { getDraftSessionByLogicalProjectKey, isServerThread, navigate, + projectGroupingSettings, routeKind, setDraftThreadContext, setLogicalProjectDraftThreadId, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cff71cf62ac..7e433dc4e89 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -31,10 +31,11 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { + type ContextMenuItem, type DesktopUpdateState, ProjectId, - type ScopedProjectRef, type ScopedThreadRef, + type SidebarProjectGroupingMode, type ThreadEnvMode, ThreadId, type GitStatusResult, @@ -59,7 +60,6 @@ import { isMacPlatform, newCommandId } from "../lib/utils"; import { selectProjectByRef, selectProjectsAcrossEnvironments, - selectSidebarThreadsForProjectRef, selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, selectThreadByRef, @@ -102,7 +102,18 @@ import { } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, @@ -142,12 +153,18 @@ import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import { deriveLogicalProjectKey } from "../logicalProject"; +import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; -import type { Project, SidebarThreadSummary } from "../types"; +import type { SidebarThreadSummary } from "../types"; +import { + buildPhysicalToLogicalProjectKeyMap, + buildSidebarProjectSnapshots, + type SidebarProjectGroupMember, + type SidebarProjectSnapshot, +} from "../sidebarProjectGrouping"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -163,6 +180,11 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { easing: "ease-out", } as const; const EMPTY_THREAD_JUMP_LABELS = new Map(); +const PROJECT_GROUPING_MODE_LABELS: Record = { + repository: "Group by repository", + repository_path: "Group by repository path", + separate: "Keep separate", +}; function threadJumpLabelMapsEqual( left: ReadonlyMap, @@ -182,6 +204,28 @@ function threadJumpLabelMapsEqual( return true; } +function formatProjectMemberActionLabel( + member: SidebarProjectGroupMember, + groupedProjectCount: number, +): string { + if (groupedProjectCount <= 1) { + return member.name; + } + + return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd; +} + +function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { + switch (mode) { + case "repository": + return "Projects from the same repository share one sidebar row."; + case "repository_path": + return "Projects group only when both the repository and repo-relative path match."; + case "separate": + return "Every project path gets its own sidebar row."; + } +} + function buildThreadJumpLabelMap(input: { keybindings: ReturnType; platform: string; @@ -212,15 +256,6 @@ function buildThreadJumpLabelMap(input: { return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } -type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; - -type SidebarProjectSnapshot = Project & { - projectKey: string; - environmentPresence: EnvironmentPresence; - memberProjectRefs: readonly ScopedProjectRef[]; - /** Labels for remote environments this project lives in. */ - remoteEnvironmentLabels: readonly string[]; -}; interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; @@ -996,6 +1031,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const defaultThreadEnvMode = useSettings( (settings) => settings.defaultThreadEnvMode, ); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); + const { updateSettings } = useUpdateSettings(); const router = useRouter(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const toggleProject = useUiStateStore((state) => state.toggleProject); @@ -1073,58 +1113,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec useShallow( useMemo( () => (state: import("../store").AppState) => - selectSidebarThreadsForProjectRef( - state, - scopeProjectRef(project.environmentId, project.id), - ), - [project.environmentId, project.id], + selectSidebarThreadsForProjectRefs(state, project.memberProjectRefs), + [project.memberProjectRefs], ), ), ); - // For grouped projects that span multiple environments, also fetch - // threads from the other member project refs. - const otherMemberRefs = useMemo( - () => - project.memberProjectRefs.filter( - (ref) => ref.environmentId !== project.environmentId || ref.projectId !== project.id, - ), - [project.memberProjectRefs, project.environmentId, project.id], - ); - const otherMemberThreads = useStore( - useShallow( - useMemo( - () => - otherMemberRefs.length === 0 - ? () => [] as SidebarThreadSummary[] - : (state: import("../store").AppState) => - selectSidebarThreadsForProjectRefs(state, otherMemberRefs), - [otherMemberRefs], - ), - ), - ); - const allSidebarThreads = useMemo( - () => - otherMemberThreads.length === 0 ? sidebarThreads : [...sidebarThreads, ...otherMemberThreads], - [sidebarThreads, otherMemberThreads], - ); const sidebarThreadByKey = useMemo( () => new Map( - allSidebarThreads.map( + sidebarThreads.map( (thread) => [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, ), ), - [allSidebarThreads], + [sidebarThreads], ); // Keep a ref so callbacks can read the latest map without appearing in // dependency arrays (avoids invalidating every thread-row memo on each // thread-list change). const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); sidebarThreadByKeyRef.current = sidebarThreadByKey; - // All threads from the representative + other member environments are - // already fetched into allSidebarThreads, so we can use them directly. - const projectThreads = allSidebarThreads; + const projectThreads = sidebarThreads; const projectExpanded = useUiStateStore( (state) => state.projectExpandedById[project.projectKey] ?? true, ); @@ -1141,9 +1150,43 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); + const [projectRenameTarget, setProjectRenameTarget] = useState( + null, + ); + const [projectRenameTitle, setProjectRenameTitle] = useState(""); + const [projectGroupingTarget, setProjectGroupingTarget] = + useState(null); + const [projectGroupingSelection, setProjectGroupingSelection] = useState< + SidebarProjectGroupingMode | "inherit" + >("inherit"); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const confirmArchiveButtonRefs = useRef(new Map()); + const memberProjectByScopedKey = useMemo( + () => + new Map( + project.memberProjects.map((member) => [ + scopedProjectKey(scopeProjectRef(member.environmentId, member.id)), + member, + ]), + ), + [project.memberProjects], + ); + const memberThreadCountByPhysicalKey = useMemo(() => { + const counts = new Map( + project.memberProjects.map((member) => [member.physicalProjectKey, 0] as const), + ); + for (const thread of projectThreads) { + const member = memberProjectByScopedKey.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ); + if (!member) { + continue; + } + counts.set(member.physicalProjectKey, (counts.get(member.physicalProjectKey) ?? 0) + 1); + } + return counts; + }, [memberProjectByScopedKey, project.memberProjects, projectThreads]); const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => { const lastVisitedAtByThreadKey = new Map( @@ -1318,6 +1361,88 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef], ); + const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => { + setProjectRenameTarget(member); + setProjectRenameTitle(member.name); + }, []); + + const openProjectGroupingDialog = useCallback( + (member: SidebarProjectGroupMember) => { + const overrideKey = deriveProjectGroupingOverrideKey(member); + setProjectGroupingTarget(member); + setProjectGroupingSelection( + projectGroupingSettings.sidebarProjectGroupingOverrides[overrideKey] ?? "inherit", + ); + }, + [projectGroupingSettings.sidebarProjectGroupingOverrides], + ); + + const handleRemoveProject = useCallback( + async (member: SidebarProjectGroupMember) => { + const api = readLocalApi(); + if (!api) { + return; + } + + if ((memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0) { + toastManager.add({ + type: "warning", + title: "Project is not empty", + description: "Delete all threads in this project before removing it.", + }); + return; + } + + const message = [ + `Remove project "${member.name}"?`, + `Path: ${member.cwd}`, + ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), + "This removes only this project entry.", + ].join("\n"); + const confirmed = await api.dialogs.confirm(message); + if (!confirmed) { + return; + } + + const memberProjectRef = scopeProjectRef(member.environmentId, member.id); + + try { + const projectDraftThread = getDraftThreadByProjectRef(memberProjectRef); + if (projectDraftThread) { + clearComposerDraftForThread(projectDraftThread.draftId); + } + clearProjectDraftThreadId(memberProjectRef); + const projectApi = readEnvironmentApi(member.environmentId); + if (!projectApi) { + throw new Error("Project API unavailable."); + } + await projectApi.orchestration.dispatchCommand({ + type: "project.delete", + commandId: newCommandId(), + projectId: member.id, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error removing project."; + console.error("Failed to remove project", { + projectId: member.id, + environmentId: member.environmentId, + error, + }); + toastManager.add({ + type: "error", + title: `Failed to remove "${member.name}"`, + description: message, + }); + } + }, + [ + clearComposerDraftForThread, + clearProjectDraftThreadId, + getDraftThreadByProjectRef, + memberThreadCountByPhysicalKey, + ], + ); + const handleProjectButtonContextMenu = useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -1326,73 +1451,103 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const api = readLocalApi(); if (!api) return; + const actionHandlers = new Map Promise | void>(); + const makeLeaf = ( + action: "rename" | "grouping" | "copy-path" | "delete", + member: SidebarProjectGroupMember, + options?: { + destructive?: boolean; + disabled?: boolean; + }, + ): ContextMenuItem => { + const id = `${action}:${member.physicalProjectKey}`; + actionHandlers.set(id, () => { + switch (action) { + case "rename": + openProjectRenameDialog(member); + return; + case "grouping": + openProjectGroupingDialog(member); + return; + case "copy-path": + copyPathToClipboard(member.cwd, { path: member.cwd }); + return; + case "delete": + return handleRemoveProject(member); + } + }); + + return { + id, + label: formatProjectMemberActionLabel(member, project.groupedProjectCount), + ...(options?.destructive ? { destructive: true } : {}), + ...(options?.disabled ? { disabled: true } : {}), + }; + }; + + const buildTargetedItem = ( + action: "rename" | "grouping" | "copy-path" | "delete", + label: string, + options?: { + destructive?: boolean; + isDisabled?: (member: SidebarProjectGroupMember) => boolean; + }, + ): ContextMenuItem => { + if (project.memberProjects.length === 1) { + const singleMember = project.memberProjects[0]!; + return { + ...makeLeaf(action, singleMember, { + ...(options?.destructive ? { destructive: true } : {}), + ...(options?.isDisabled?.(singleMember) ? { disabled: true } : {}), + }), + label, + }; + } + + return { + id: `${action}:submenu`, + label, + children: project.memberProjects.map((member) => + makeLeaf(action, member, { + ...(options?.destructive ? { destructive: true } : {}), + ...(options?.isDisabled?.(member) ? { disabled: true } : {}), + }), + ), + }; + }; + const clicked = await api.contextMenu.show( [ - { id: "copy-path", label: "Copy Project Path" }, - { id: "delete", label: "Remove project", destructive: true }, + buildTargetedItem("rename", "Rename project"), + buildTargetedItem("grouping", "Project grouping…"), + buildTargetedItem("copy-path", "Copy Project Path"), + buildTargetedItem("delete", "Remove project", { + destructive: true, + isDisabled: (member) => + (memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0, + }), ], { x: event.clientX, y: event.clientY, }, ); - if (clicked === "copy-path") { - copyPathToClipboard(project.cwd, { path: project.cwd }); - return; - } - if (clicked !== "delete") return; - if (projectThreads.length > 0) { - toastManager.add({ - type: "warning", - title: "Project is not empty", - description: "Delete all threads in this project before removing it.", - }); + if (!clicked) { return; } - const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); - if (!confirmed) return; - - try { - const projectDraftThread = getDraftThreadByProjectRef( - scopeProjectRef(project.environmentId, project.id), - ); - if (projectDraftThread) { - clearComposerDraftForThread(projectDraftThread.draftId); - } - clearProjectDraftThreadId(scopeProjectRef(project.environmentId, project.id)); - const projectApi = readEnvironmentApi(project.environmentId); - if (!projectApi) { - throw new Error("Project API unavailable."); - } - await projectApi.orchestration.dispatchCommand({ - type: "project.delete", - commandId: newCommandId(), - projectId: project.id, - }); - } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown error removing project."; - console.error("Failed to remove project", { projectId: project.id, error }); - toastManager.add({ - type: "error", - title: `Failed to remove "${project.name}"`, - description: message, - }); - } + await actionHandlers.get(clicked)?.(); })(); }, [ - clearComposerDraftForThread, - clearProjectDraftThreadId, copyPathToClipboard, - getDraftThreadByProjectRef, - project.cwd, - project.environmentId, - project.id, - project.name, - projectThreads.length, + handleRemoveProject, + memberThreadCountByPhysicalKey, + openProjectGroupingDialog, + openProjectRenameDialog, + project.groupedProjectCount, + project.memberProjects, suppressProjectClickForContextMenuRef, ], ); @@ -1503,10 +1658,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ], ); - const handleCreateThreadClick = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); + const createThreadForProjectMember = useCallback( + (member: SidebarProjectGroupMember) => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); @@ -1522,12 +1675,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null) : null; const seedContext = resolveSidebarNewThreadSeedContext({ - projectId: project.id, + projectId: member.id, defaultEnvMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: defaultThreadEnvMode, }), activeThread: - currentActiveThread && currentActiveThread.projectId === project.id + currentActiveThread && currentActiveThread.projectId === member.id ? { projectId: currentActiveThread.projectId, branch: currentActiveThread.branch, @@ -1535,7 +1688,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } : null, activeDraftThread: - currentActiveDraftThread && currentActiveDraftThread.projectId === project.id + currentActiveDraftThread && currentActiveDraftThread.projectId === member.id ? { projectId: currentActiveDraftThread.projectId, branch: currentActiveDraftThread.branch, @@ -1544,7 +1697,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } : null, }); - void handleNewThread(scopeProjectRef(project.environmentId, project.id), { + void handleNewThread(scopeProjectRef(member.environmentId, member.id), { ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), ...(seedContext.worktreePath !== undefined ? { worktreePath: seedContext.worktreePath } @@ -1552,7 +1705,47 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec envMode: seedContext.envMode, }); }, - [defaultThreadEnvMode, handleNewThread, project.environmentId, project.id, router], + [defaultThreadEnvMode, handleNewThread, router], + ); + + const handleCreateThreadClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (project.memberProjects.length === 1) { + createThreadForProjectMember(project.memberProjects[0]!); + return; + } + + void (async () => { + const api = readLocalApi(); + if (!api) { + return; + } + const clicked = await api.contextMenu.show( + project.memberProjects.map((member) => ({ + id: member.physicalProjectKey, + label: formatProjectMemberActionLabel(member, project.groupedProjectCount), + })), + { + x: event.clientX, + y: event.clientY, + }, + ); + if (!clicked) { + return; + } + const targetMember = project.memberProjects.find( + (member) => member.physicalProjectKey === clicked, + ); + if (!targetMember) { + return; + } + createThreadForProjectMember(targetMember); + })(); + }, + [createThreadForProjectMember, project.groupedProjectCount, project.memberProjects], ); const attemptArchiveThread = useCallback( @@ -1623,6 +1816,88 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [], ); + const closeProjectRenameDialog = useCallback(() => { + setProjectRenameTarget(null); + setProjectRenameTitle(""); + }, []); + + const submitProjectRename = useCallback(async () => { + if (!projectRenameTarget) { + return; + } + + const trimmed = projectRenameTitle.trim(); + if (trimmed.length === 0) { + toastManager.add({ + type: "warning", + title: "Project title cannot be empty", + }); + return; + } + + if (trimmed === projectRenameTarget.name) { + closeProjectRenameDialog(); + return; + } + + const api = readEnvironmentApi(projectRenameTarget.environmentId); + if (!api) { + toastManager.add({ + type: "error", + title: "Failed to rename project", + description: "Project API unavailable.", + }); + return; + } + + try { + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: projectRenameTarget.id, + title: trimmed, + }); + closeProjectRenameDialog(); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to rename project", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]); + + const closeProjectGroupingDialog = useCallback(() => { + setProjectGroupingTarget(null); + setProjectGroupingSelection("inherit"); + }, []); + + const saveProjectGroupingPreference = useCallback(() => { + if (!projectGroupingTarget) { + return; + } + + const overrideKey = deriveProjectGroupingOverrideKey(projectGroupingTarget); + const nextOverrides = { + ...projectGroupingSettings.sidebarProjectGroupingOverrides, + }; + if (projectGroupingSelection === "inherit") { + delete nextOverrides[overrideKey]; + } else { + nextOverrides[overrideKey] = projectGroupingSelection; + } + updateSettings({ + sidebarProjectGroupingOverrides: nextOverrides, + }); + closeProjectGroupingDialog(); + }, [ + closeProjectGroupingDialog, + projectGroupingSelection, + projectGroupingSettings.sidebarProjectGroupingOverrides, + projectGroupingTarget, + updateSettings, + ]); + const handleThreadContextMenu = useCallback( async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { const api = readLocalApi(); @@ -1630,7 +1905,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const threadKey = scopedThreadKey(threadRef); const thread = sidebarThreadByKeyRef.current.get(threadKey) ?? null; if (!thread) return; - const threadWorkspacePath = thread.worktreePath ?? project.cwd ?? null; + const threadProject = memberProjectByScopedKey.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ); + const threadWorkspacePath = thread.worktreePath ?? threadProject?.cwd ?? project.cwd ?? null; const clicked = await api.contextMenu.show( [ { id: "rename", label: "Rename thread" }, @@ -1689,6 +1967,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyThreadIdToClipboard, deleteThread, markThreadUnread, + memberProjectByScopedKey, project.cwd, ], ); @@ -1732,8 +2011,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec /> )} - - {project.name} + + + {project.displayName} + + {project.groupedProjectCount > 1 ? ( + + {project.groupedProjectCount} projects + + ) : null} {/* Environment badge – visible by default, crossfades with the @@ -1766,7 +2052,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
+ + + + + + { + if (!open) { + closeProjectGroupingDialog(); + } + }} + > + + + Project grouping + + {projectGroupingTarget + ? `Choose how ${projectGroupingTarget.cwd} should be grouped in the sidebar.` + : "Choose how this project should be grouped in the sidebar."} + + + +
+ Grouping rule + +
+

+ {projectGroupingSelection === "inherit" + ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode) + : projectGroupingModeDescription(projectGroupingSelection)} +

+
+ + + + +
+
); }); @@ -2282,6 +2686,10 @@ export default function Sidebar() { const isOnSettings = pathname.startsWith("/settings"); const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); @@ -2319,79 +2727,36 @@ export default function Sidebar() { // cross-environment grouping. Projects that share a repositoryIdentity // canonicalKey are treated as one logical project in the sidebar. const physicalToLogicalKey = useMemo(() => { - const mapping = new Map(); - for (const project of orderedProjects) { - const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); - mapping.set(physicalKey, deriveLogicalProjectKey(project)); - } - return mapping; - }, [orderedProjects]); + return buildPhysicalToLogicalProjectKeyMap({ + projects: orderedProjects, + settings: projectGroupingSettings, + }); + }, [orderedProjects, projectGroupingSettings]); + const projectPhysicalKeyByScopedRef = useMemo( + () => + new Map( + orderedProjects.map((project) => [ + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + derivePhysicalProjectKey(project), + ]), + ), + [orderedProjects], + ); const sidebarProjects = useMemo(() => { - // Group projects by logical key while preserving insertion order from - // orderedProjects. - const groupedMembers = new Map(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - const existing = groupedMembers.get(logicalKey); - if (existing) { - existing.push(project); - } else { - groupedMembers.set(logicalKey, [project]); - } - } - - const result: SidebarProjectSnapshot[] = []; - const seen = new Set(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - if (seen.has(logicalKey)) continue; - seen.add(logicalKey); - - const members = groupedMembers.get(logicalKey)!; - // Prefer the primary environment's project as the representative. - const representative: Project | undefined = - (primaryEnvironmentId - ? members.find((p) => p.environmentId === primaryEnvironmentId) - : undefined) ?? members[0]; - if (!representative) continue; - const hasLocal = - primaryEnvironmentId !== null && - members.some((p) => p.environmentId === primaryEnvironmentId); - const hasRemote = - primaryEnvironmentId !== null - ? members.some((p) => p.environmentId !== primaryEnvironmentId) - : false; - - const refs = members.map((p) => scopeProjectRef(p.environmentId, p.id)); - const remoteLabels = members - .filter((p) => primaryEnvironmentId !== null && p.environmentId !== primaryEnvironmentId) - .map((p) => { - const rt = savedEnvironmentRuntimeById[p.environmentId]; - const saved = savedEnvironmentRegistry[p.environmentId]; - return rt?.descriptor?.label ?? saved?.label ?? p.environmentId; - }); - const snapshot: SidebarProjectSnapshot = { - id: representative.id, - environmentId: representative.environmentId, - name: representative.name, - cwd: representative.cwd, - repositoryIdentity: representative.repositoryIdentity ?? null, - defaultModelSelection: representative.defaultModelSelection, - createdAt: representative.createdAt, - updatedAt: representative.updatedAt, - scripts: representative.scripts, - projectKey: logicalKey, - environmentPresence: - hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", - memberProjectRefs: refs, - remoteEnvironmentLabels: remoteLabels, - }; - result.push(snapshot); - } - return result; + return buildSidebarProjectSnapshots({ + projects: orderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => { + const rt = savedEnvironmentRuntimeById[environmentId]; + const saved = savedEnvironmentRegistry[environmentId]; + return rt?.descriptor?.label ?? saved?.label ?? environmentId; + }, + }); }, [ orderedProjects, + projectGroupingSettings, primaryEnvironmentId, savedEnvironmentRegistry, savedEnvironmentRuntimeById, @@ -2419,18 +2784,22 @@ export default function Sidebar() { } const activeThread = sidebarThreadByKey.get(routeThreadKey); if (!activeThread) return null; - const physicalKey = scopedProjectKey( - scopeProjectRef(activeThread.environmentId, activeThread.projectId), - ); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)); return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]); + }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); // Group threads by logical project key so all threads from grouped projects // are displayed together. const threadsByProjectKey = useMemo(() => { const next = new Map(); for (const thread of sidebarThreads) { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; const existing = next.get(logicalKey); if (existing) { @@ -2440,7 +2809,7 @@ export default function Sidebar() { } } return next; - }, [sidebarThreads, physicalToLogicalKey]); + }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -2507,8 +2876,10 @@ export default function Sidebar() { const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); const overProject = sidebarProjects.find((project) => project.projectKey === over.id); if (!activeProject || !overProject) return; - const activeMemberKeys = activeProject.memberProjectRefs.map(scopedProjectKey); - const overMemberKeys = overProject.memberProjectRefs.map(scopedProjectKey); + const activeMemberKeys = activeProject.memberProjects.map( + (member) => member.physicalProjectKey, + ); + const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); reorderProjects(activeMemberKeys, overMemberKeys); }, [sidebarProjectSortOrder, reorderProjects, sidebarProjects], @@ -2557,7 +2928,10 @@ export default function Sidebar() { id: project.projectKey, })); const sortableThreads = visibleThreads.map((thread) => { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); return { ...thread, projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, @@ -2574,6 +2948,7 @@ export default function Sidebar() { }, [ sidebarProjectSortOrder, physicalToLogicalKey, + projectPhysicalKeyByScopedRef, sidebarProjectByKey, sidebarProjects, visibleThreads, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f76c69581d8..ece02bf5c6e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -19,7 +19,10 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { + DEFAULT_UNIFIED_SETTINGS, + type SidebarProjectGroupingMode, +} from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; @@ -99,6 +102,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const PROJECT_GROUPING_LABELS: Record = { + repository: "Repository", + repository_path: "Repository path", + separate: "Separate projects", +}; + type InstallProviderSettings = { provider: ProviderKind; title: string; @@ -832,6 +841,51 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, + }) + } + /> + ) : null + } + control={ + + } + /> + void; + +class FakeDomEvent { + defaultPrevented = false; + + constructor( + readonly type: string, + init: Record = {}, + ) { + Object.assign(this, init); + } + + preventDefault() { + this.defaultPrevented = true; + } +} + +class FakeElement { + children: FakeElement[] = []; + parent: FakeElement | null = null; + style: Record & { cssText?: string } = {}; + dataset: Record = {}; + className = ""; + disabled = false; + type = ""; + private textValue = ""; + private readonly listeners = new Map(); + + constructor(readonly tagName: string) {} + + appendChild(child: FakeElement) { + child.parent = this; + this.children.push(child); + return child; + } + + remove() { + if (!this.parent) { + return; + } + const index = this.parent.children.indexOf(this); + if (index >= 0) { + this.parent.children.splice(index, 1); + } + this.parent = null; + } + + addEventListener(type: string, listener: FakeListener) { + const existing = this.listeners.get(type) ?? []; + existing.push(listener); + this.listeners.set(type, existing); + } + + dispatchEvent(event: FakeDomEvent) { + for (const listener of this.listeners.get(event.type) ?? []) { + listener(event); + } + return true; + } + + set textContent(value: string) { + this.textValue = value; + } + + get textContent() { + return `${this.textValue}${this.children.map((child) => child.textContent).join("")}`; + } + + querySelectorAll(tagName: string): FakeElement[] { + const matches: FakeElement[] = []; + if (this.tagName === tagName) { + matches.push(this); + } + for (const child of this.children) { + matches.push(...child.querySelectorAll(tagName)); + } + return matches; + } + + getBoundingClientRect() { + const left = Number.parseInt(this.style.left ?? "0", 10) || 0; + const top = Number.parseInt(this.style.top ?? "0", 10) || 0; + const width = this.tagName === "div" ? 180 : 140; + const height = this.tagName === "div" ? 120 : 28; + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }; + } +} + +class FakeBody extends FakeElement { + private html = ""; + + constructor() { + super("body"); + } + + set innerHTML(value: string) { + this.html = value; + this.children = []; + } + + get innerHTML() { + return this.html; + } +} + +class FakeDocument { + body = new FakeBody(); + private readonly listeners = new Map(); + + createElement(tagName: string) { + return new FakeElement(tagName); + } + + addEventListener(type: string, listener: FakeListener) { + const existing = this.listeners.get(type) ?? []; + existing.push(listener); + this.listeners.set(type, existing); + } + + removeEventListener(type: string, listener: FakeListener) { + const existing = this.listeners.get(type); + if (!existing) { + return; + } + const index = existing.indexOf(listener); + if (index >= 0) { + existing.splice(index, 1); + } + } + + querySelectorAll(tagName: string) { + return this.body.querySelectorAll(tagName); + } +} + +function findButton(label: string): FakeElement | undefined { + return (document as unknown as FakeDocument) + .querySelectorAll("button") + .find((button) => button.textContent.includes(label)); +} + +beforeEach(() => { + vi.stubGlobal("document", new FakeDocument()); + vi.stubGlobal("window", { + innerWidth: 1280, + innerHeight: 800, + }); + vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { + callback(0); + return 0; + }); + vi.stubGlobal( + "MouseEvent", + class extends FakeDomEvent { + constructor(type: string, init: Record = {}) { + super(type, init); + } + }, + ); + vi.stubGlobal( + "KeyboardEvent", + class extends FakeDomEvent { + constructor(type: string, init: Record = {}) { + super(type, init); + } + }, + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("showContextMenuFallback", () => { + it("resolves a clicked flat menu item", async () => { + const selectionPromise = showContextMenuFallback([ + { id: "rename", label: "Rename" }, + { id: "delete", label: "Delete", destructive: true }, + ]); + + const renameButton = findButton("Rename"); + expect(renameButton).toBeTruthy(); + renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("rename"); + }); + + it("opens nested submenus and resolves the clicked leaf id", async () => { + const selectionPromise = showContextMenuFallback([ + { + id: "rename:submenu", + label: "Rename project", + children: [ + { id: "rename:project-a", label: "/tmp/project-a" }, + { id: "rename:project-b", label: "/tmp/project-b" }, + ], + }, + ]); + + const parentButton = findButton("Rename project"); + expect(parentButton).toBeTruthy(); + parentButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + + const childButton = findButton("/tmp/project-b"); + expect(childButton).toBeTruthy(); + childButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("rename:project-b"); + }); +}); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 9fd1a12956e..cda90df5d11 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -1,9 +1,22 @@ import type { ContextMenuItem } from "@t3tools/contracts"; +function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferredTop: number) { + const rect = menu.getBoundingClientRect(); + const left = Math.min( + Math.max(4, preferredLeft), + Math.max(4, window.innerWidth - rect.width - 4), + ); + const top = Math.min( + Math.max(4, preferredTop), + Math.max(4, window.innerHeight - rect.height - 4), + ); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; +} + /** * Imperative DOM-based context menu for non-Electron environments. - * Shows a positioned dropdown and returns a promise that resolves - * with the clicked item id, or null if dismissed. + * Supports nested submenus and resolves with the clicked leaf item id. */ export function showContextMenuFallback( items: readonly ContextMenuItem[], @@ -13,62 +26,117 @@ export function showContextMenuFallback( const overlay = document.createElement("div"); overlay.style.cssText = "position:fixed;inset:0;z-index:9999"; - const menu = document.createElement("div"); - menu.className = - "fixed z-[10000] min-w-[140px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; - - const x = position?.x ?? 0; - const y = position?.y ?? 0; - menu.style.top = `${y}px`; - menu.style.left = `${x}px`; + const menuStack: HTMLDivElement[] = []; - function cleanup(result: T | null) { + const cleanup = (result: T | null) => { document.removeEventListener("keydown", onKeyDown); overlay.remove(); - menu.remove(); + for (const menu of menuStack) { + menu.remove(); + } resolve(result); - } + }; - function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - e.preventDefault(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); cleanup(null); } - } + }; - overlay.addEventListener("mousedown", () => cleanup(null)); - document.addEventListener("keydown", onKeyDown); - - for (const item of items) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.textContent = item.label; - const isDestructiveAction = item.destructive === true || item.id === "delete"; - const isDisabled = item.disabled === true; - btn.disabled = isDisabled; - btn.className = isDisabled - ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed" - : isDestructiveAction - ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" - : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; - if (!isDisabled) { - btn.addEventListener("click", () => cleanup(item.id)); + const closeMenusFromLevel = (level: number) => { + while (menuStack.length > level) { + menuStack.pop()?.remove(); } - menu.appendChild(btn); - } + }; - document.body.appendChild(overlay); - document.body.appendChild(menu); + const openMenu = ( + entries: readonly ContextMenuItem[], + preferredLeft: number, + preferredTop: number, + level: number, + ) => { + closeMenusFromLevel(level); - // Adjust if menu overflows viewport - requestAnimationFrame(() => { - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) { - menu.style.left = `${window.innerWidth - rect.width - 4}px`; - } - if (rect.bottom > window.innerHeight) { - menu.style.top = `${window.innerHeight - rect.height - 4}px`; + const menu = document.createElement("div"); + menu.className = + "fixed z-[10000] min-w-[160px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; + menu.style.left = `${preferredLeft}px`; + menu.style.top = `${preferredTop}px`; + menu.dataset.level = String(level); + + for (const item of entries) { + const button = document.createElement("button"); + button.type = "button"; + const hasChildren = Array.isArray(item.children) && item.children.length > 0; + const isLeafDestructive = + !hasChildren && (item.destructive === true || item.id === ("delete" as T)); + const isDisabled = item.disabled === true; + button.disabled = isDisabled; + button.className = isDisabled + ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed" + : isLeafDestructive + ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" + : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; + + const label = document.createElement("span"); + label.className = "min-w-0 flex-1 truncate"; + label.textContent = item.label; + button.appendChild(label); + + if (hasChildren) { + const chevron = document.createElement("span"); + chevron.className = "shrink-0 text-muted-foreground/70"; + chevron.textContent = "›"; + button.appendChild(chevron); + } + + if (!isDisabled) { + if (hasChildren) { + button.addEventListener("mouseenter", () => { + const rect = button.getBoundingClientRect(); + const nextLeft = rect.right + 4; + const nextTop = rect.top; + openMenu(item.children!, nextLeft, nextTop, level + 1); + + const childMenu = menuStack[level + 1]; + if (!childMenu) { + return; + } + const childRect = childMenu.getBoundingClientRect(); + if (childRect.right > window.innerWidth) { + clampMenuPosition(childMenu, rect.left - childRect.width - 4, rect.top); + } + }); + button.addEventListener("click", (event) => { + event.preventDefault(); + }); + } else { + button.addEventListener("mouseenter", () => { + closeMenusFromLevel(level + 1); + }); + button.addEventListener("click", () => cleanup(item.id)); + } + } + + menu.appendChild(button); } - }); + + menu.addEventListener("mouseenter", () => { + closeMenusFromLevel(level + 1); + }); + + document.body.appendChild(menu); + menuStack[level] = menu; + + requestAnimationFrame(() => { + clampMenuPosition(menu, preferredLeft, preferredTop); + }); + }; + + overlay.addEventListener("mousedown", () => cleanup(null)); + document.addEventListener("keydown", onKeyDown); + document.body.appendChild(overlay); + openMenu(items, position?.x ?? 0, position?.y ?? 0, 0); }); } diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 570c18ff7f4..9ae26fe8b46 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -10,7 +10,12 @@ import { type AppState, type EnvironmentState, } from "./store"; -import { deriveLogicalProjectKey } from "./logicalProject"; +import { + deriveLogicalProjectKey, + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + resolveProjectGroupingMode, +} from "./logicalProject"; import type { Project, SidebarThreadSummary } from "./types"; import { DEFAULT_INTERACTION_MODE } from "./types"; @@ -31,6 +36,10 @@ const threadL1 = ThreadId.make("thread-local-only-1"); const threadRO1 = ThreadId.make("thread-remote-only-1"); const SHARED_REPO_CANONICAL_KEY = "github.com/example/shared-repo"; +const DEFAULT_GROUPING_SETTINGS = { + sidebarProjectGroupingMode: "repository" as const, + sidebarProjectGroupingOverrides: {}, +}; // ── Factory Helpers ────────────────────────────────────────────────── @@ -238,9 +247,7 @@ describe("environment grouping", () => { environmentId: primaryEnvId, name: "local-only", }); - const key = deriveLogicalProjectKey(project); - expect(key).toContain(primaryEnvId); - expect(key).toContain(localOnlyProjectId); + expect(deriveLogicalProjectKey(project)).toBe(derivePhysicalProjectKey(project)); }); it("groups projects from different environments that share the same canonical key", () => { @@ -273,7 +280,7 @@ describe("environment grouping", () => { expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote)); }); - it("does not group repo root and nested projects from the same repository", () => { + it("groups repo root and nested projects from the same repository by default", () => { const rootProject = makeProject({ id: sharedProjectPrimaryId, environmentId: primaryEnvId, @@ -306,7 +313,51 @@ describe("environment grouping", () => { }); expect(deriveLogicalProjectKey(rootProject)).toBe(SHARED_REPO_CANONICAL_KEY); - expect(deriveLogicalProjectKey(nestedProject)).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); + expect(deriveLogicalProjectKey(nestedProject)).toBe(SHARED_REPO_CANONICAL_KEY); + }); + + it("uses repository path grouping when requested", () => { + const rootProject = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "shared-repo", + cwd: "/workspace/repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + const nestedProject = makeProject({ + id: localOnlyProjectId, + environmentId: primaryEnvId, + name: "web", + cwd: "/workspace/repo/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect( + deriveLogicalProjectKey(rootProject, { + groupingMode: "repository_path", + }), + ).toBe(SHARED_REPO_CANONICAL_KEY); + expect( + deriveLogicalProjectKey(nestedProject, { + groupingMode: "repository_path", + }), + ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); }); it("groups matching nested project paths across environments when repo roots differ", () => { @@ -341,8 +392,20 @@ describe("environment grouping", () => { }, }); - expect(deriveLogicalProjectKey(primary)).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); - expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote)); + expect( + deriveLogicalProjectKey(primary, { + groupingMode: "repository_path", + }), + ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); + expect( + deriveLogicalProjectKey(primary, { + groupingMode: "repository_path", + }), + ).toBe( + deriveLogicalProjectKey(remote, { + groupingMode: "repository_path", + }), + ); }); it("does NOT group projects without shared canonical key", () => { @@ -358,6 +421,32 @@ describe("environment grouping", () => { }); expect(deriveLogicalProjectKey(local)).not.toBe(deriveLogicalProjectKey(remote)); }); + + it("uses per-project overrides from settings", () => { + const project = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "shared-repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect(resolveProjectGroupingMode(project, DEFAULT_GROUPING_SETTINGS)).toBe("repository"); + expect( + deriveLogicalProjectKeyFromSettings(project, { + ...DEFAULT_GROUPING_SETTINGS, + sidebarProjectGroupingOverrides: { + [derivePhysicalProjectKey(project)]: "separate", + }, + }), + ).toBe(derivePhysicalProjectKey(project)); + }); }); describe("selectProjectsAcrossEnvironments", () => { diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 086bff6b377..779678a403f 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -13,9 +13,7 @@ import { Throttler } from "@tanstack/react-pacer"; import { createKnownEnvironment, getKnownEnvironmentWsBaseUrl, - scopedProjectKey, scopedThreadKey, - scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; @@ -62,6 +60,7 @@ import { useTerminalStateStore } from "~/terminalStateStore"; import { useUiStateStore } from "~/uiStateStore"; import { WsTransport } from "../../rpc/wsTransport"; import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient"; +import { derivePhysicalProjectKey } from "../../logicalProject"; type EnvironmentServiceState = { readonly queryClient: QueryClient; @@ -470,7 +469,7 @@ function syncProjectUiFromStore() { const projects = selectProjectsAcrossEnvironments(useStore.getState()); useUiStateStore.getState().syncProjects( projects.map((project) => ({ - key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + key: derivePhysicalProjectKey(project), cwd: project.cwd, })), ); @@ -543,7 +542,7 @@ function applyRecoveredEventBatch( const projects = selectProjectsAcrossEnvironments(useStore.getState()); useUiStateStore.getState().syncProjects( projects.map((project) => ({ - key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + key: derivePhysicalProjectKey(project), cwd: project.cwd, })), ); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index f567735691c..d512b6c7e76 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -10,14 +10,19 @@ import { } from "../composerDraftStore"; import { newDraftId, newThreadId } from "../lib/utils"; import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; -import { deriveLogicalProjectKey } from "../logicalProject"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { selectProjectsAcrossEnvironments, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { useUiStateStore } from "../uiStateStore"; +import { useSettings } from "./useSettings"; function useNewThreadState() { const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; @@ -48,7 +53,7 @@ function useNewThreadState() { candidate.environmentId === projectRef.environmentId, ); const logicalProjectKey = project - ? deriveLogicalProjectKey(project) + ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) : scopedProjectKey(projectRef); const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; @@ -129,7 +134,7 @@ function useNewThreadState() { }); })(); }, - [getCurrentRouteTarget, router, projects], + [getCurrentRouteTarget, projectGroupingSettings, router, projects], ); } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 06b163137b4..4258ccb3802 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -528,13 +528,20 @@ describe("wsApi", () => { }); it("reads and writes persistence through the desktop bridge when available", async () => { - const getClientSettings = vi.fn().mockResolvedValue({ + const clientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", + sidebarProjectGroupingMode: "repository_path" as const, + sidebarProjectGroupingOverrides: { + "environment-local:/tmp/project": "separate" as const, + }, + sidebarProjectSortOrder: "manual" as const, + sidebarThreadSortOrder: "created_at" as const, + timestampFormat: "24-hour" as const, + }; + const getClientSettings = vi.fn().mockResolvedValue({ + ...clientSettings, }); const setClientSettings = vi.fn().mockResolvedValue(undefined); const getSavedEnvironmentRegistry = vi.fn().mockResolvedValue([]); @@ -556,14 +563,7 @@ describe("wsApi", () => { const api = createLocalApi(rpcClientMock as never); await api.persistence.getClientSettings(); - await api.persistence.setClientSettings({ - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - }); + await api.persistence.setClientSettings(clientSettings); await api.persistence.getSavedEnvironmentRegistry(); await api.persistence.setSavedEnvironmentRegistry([]); await api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")); @@ -574,14 +574,7 @@ describe("wsApi", () => { await api.persistence.removeSavedEnvironmentSecret(EnvironmentId.make("environment-local")); expect(getClientSettings).toHaveBeenCalledWith(); - expect(setClientSettings).toHaveBeenCalledWith({ - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - }); + expect(setClientSettings).toHaveBeenCalledWith(clientSettings); expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith(); expect(setSavedEnvironmentRegistry).toHaveBeenCalledWith([]); expect(getSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); @@ -592,15 +585,20 @@ describe("wsApi", () => { it("falls back to browser storage for persistence when the desktop bridge is missing", async () => { const { createLocalApi } = await import("./localApi"); const api = createLocalApi(rpcClientMock as never); - - await api.persistence.setClientSettings({ + const clientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - }); + sidebarProjectGroupingMode: "repository_path" as const, + sidebarProjectGroupingOverrides: { + "environment-local:/tmp/project": "separate" as const, + }, + sidebarProjectSortOrder: "manual" as const, + sidebarThreadSortOrder: "created_at" as const, + timestampFormat: "24-hour" as const, + }; + + await api.persistence.setClientSettings(clientSettings); await api.persistence.setSavedEnvironmentRegistry([ { environmentId: EnvironmentId.make("environment-local"), @@ -616,14 +614,7 @@ describe("wsApi", () => { "bearer-token", ); - await expect(api.persistence.getClientSettings()).resolves.toEqual({ - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - }); + await expect(api.persistence.getClientSettings()).resolves.toEqual(clientSettings); await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([ { environmentId: EnvironmentId.make("environment-local"), diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index 2ef8a4f0f37..6b84fa6dc34 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -1,8 +1,29 @@ import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; -import type { ScopedProjectRef } from "@t3tools/contracts"; +import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; import type { Project } from "./types"; +export interface ProjectGroupingSettings { + sidebarProjectGroupingMode: SidebarProjectGroupingMode; + sidebarProjectGroupingOverrides: Record; +} + +export type ProjectGroupingMode = SidebarProjectGroupingMode; + +function uniqueNonEmptyValues(values: ReadonlyArray): string[] { + const seen = new Set(); + const unique: string[] = []; + for (const value of values) { + const trimmed = value?.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + unique.push(trimmed); + } + return unique; +} + function deriveRepositoryRelativeProjectPath( project: Pick, ): string | null { @@ -30,14 +51,43 @@ function deriveRepositoryRelativeProjectPath( return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/"); } +export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string { + return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; +} + +export function derivePhysicalProjectKey(project: Pick): string { + return derivePhysicalProjectKeyFromPath(project.environmentId, project.cwd); +} + +export function deriveProjectGroupingOverrideKey( + project: Pick, +): string { + return derivePhysicalProjectKey(project); +} + +export function resolveProjectGroupingMode( + project: Pick, + settings: ProjectGroupingSettings, +): SidebarProjectGroupingMode { + return ( + settings.sidebarProjectGroupingOverrides[deriveProjectGroupingOverrideKey(project)] ?? + settings.sidebarProjectGroupingMode + ); +} + function deriveRepositoryScopedKey( project: Pick, + groupingMode: SidebarProjectGroupingMode, ): string | null { const canonicalKey = project.repositoryIdentity?.canonicalKey; if (!canonicalKey) { return null; } + if (groupingMode === "repository") { + return canonicalKey; + } + const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); if (relativeProjectPath === null) { return canonicalKey; @@ -50,16 +100,58 @@ function deriveRepositoryScopedKey( export function deriveLogicalProjectKey( project: Pick, + options?: { + groupingMode?: SidebarProjectGroupingMode; + }, ): string { + const groupingMode = options?.groupingMode ?? "repository"; + if (groupingMode === "separate") { + return derivePhysicalProjectKey(project); + } + return ( - deriveRepositoryScopedKey(project) ?? + deriveRepositoryScopedKey(project, groupingMode) ?? + derivePhysicalProjectKey(project) ?? scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) ); } +export function deriveLogicalProjectKeyFromSettings( + project: Pick, + settings: ProjectGroupingSettings, +): string { + return deriveLogicalProjectKey(project, { + groupingMode: resolveProjectGroupingMode(project, settings), + }); +} + export function deriveLogicalProjectKeyFromRef( projectRef: ScopedProjectRef, - project: Pick | null | undefined, + project: Pick | null | undefined, + options?: { + groupingMode?: SidebarProjectGroupingMode; + }, ): string { - return (project ? deriveRepositoryScopedKey(project) : null) ?? scopedProjectKey(projectRef); + return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef); +} + +export function deriveProjectGroupLabel(input: { + representative: Pick; + members: ReadonlyArray>; +}): string { + const sharedDisplayNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.displayName), + ); + if (sharedDisplayNames.length === 1) { + return sharedDisplayNames[0]!; + } + + const sharedRepositoryNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.name), + ); + if (sharedRepositoryNames.length === 1) { + return sharedRepositoryNames[0]!; + } + + return input.representative.name; } diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts new file mode 100644 index 00000000000..8909c1bf755 --- /dev/null +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -0,0 +1,118 @@ +import { scopeProjectRef } from "@t3tools/client-runtime"; +import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + deriveProjectGroupLabel, + type ProjectGroupingSettings, +} from "./logicalProject"; +import type { Project } from "./types"; + +export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; + +export interface SidebarProjectGroupMember extends Project { + physicalProjectKey: string; + environmentLabel: string | null; +} + +export interface SidebarProjectSnapshot extends Project { + projectKey: string; + displayName: string; + groupedProjectCount: number; + environmentPresence: EnvironmentPresence; + memberProjects: readonly SidebarProjectGroupMember[]; + memberProjectRefs: readonly ScopedProjectRef[]; + remoteEnvironmentLabels: readonly string[]; +} + +export function buildPhysicalToLogicalProjectKeyMap(input: { + projects: ReadonlyArray; + settings: ProjectGroupingSettings; +}): Map { + const mapping = new Map(); + for (const project of input.projects) { + mapping.set( + derivePhysicalProjectKey(project), + deriveLogicalProjectKeyFromSettings(project, input.settings), + ); + } + return mapping; +} + +export function buildSidebarProjectSnapshots(input: { + projects: ReadonlyArray; + settings: ProjectGroupingSettings; + primaryEnvironmentId: EnvironmentId | null; + resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; +}): SidebarProjectSnapshot[] { + const groupedMembers = new Map(); + for (const project of input.projects) { + const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); + const member: SidebarProjectGroupMember = { + ...project, + physicalProjectKey: derivePhysicalProjectKey(project), + environmentLabel: input.resolveEnvironmentLabel(project.environmentId), + }; + const existing = groupedMembers.get(logicalKey); + if (existing) { + existing.push(member); + } else { + groupedMembers.set(logicalKey, [member]); + } + } + + const result: SidebarProjectSnapshot[] = []; + const seen = new Set(); + for (const project of input.projects) { + const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); + if (seen.has(logicalKey)) { + continue; + } + seen.add(logicalKey); + + const members = groupedMembers.get(logicalKey) ?? []; + const representative = + (input.primaryEnvironmentId + ? members.find((member) => member.environmentId === input.primaryEnvironmentId) + : null) ?? members[0]; + if (!representative) { + continue; + } + + const hasLocal = + input.primaryEnvironmentId !== null && + members.some((member) => member.environmentId === input.primaryEnvironmentId); + const hasRemote = + input.primaryEnvironmentId !== null + ? members.some((member) => member.environmentId !== input.primaryEnvironmentId) + : false; + const remoteEnvironmentLabels = members + .filter( + (member) => + input.primaryEnvironmentId !== null && + member.environmentId !== input.primaryEnvironmentId, + ) + .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : [])) + .filter((label, index, labels) => labels.indexOf(label) === index); + + result.push({ + ...representative, + projectKey: logicalKey, + displayName: + members.length > 1 + ? deriveProjectGroupLabel({ + representative, + members, + }) + : representative.name, + groupedProjectCount: members.length, + environmentPresence: + hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", + memberProjects: members, + memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)), + remoteEnvironmentLabels, + }); + } + + return result; +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index c2d68133015..e9cc28736a5 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -60,6 +60,7 @@ export interface ContextMenuItem { label: string; destructive?: boolean; disabled?: boolean; + children?: readonly ContextMenuItem[]; } export type DesktopUpdateStatus = diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 375f4ed27e2..28723cf254d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -23,10 +23,25 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +export const SidebarProjectGroupingMode = Schema.Literals([ + "repository", + "repository_path", + "separate", +]); +export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; +export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; + export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), + ), + sidebarProjectGroupingOverrides: Schema.Record( + TrimmedNonEmptyString, + SidebarProjectGroupingMode, + ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)), ), From ce291f51414db826269a32dab913d0af93f72f6c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 15 Apr 2026 23:58:32 -0700 Subject: [PATCH 3/5] Expose project grouping in the sidebar menu - lets users change project grouping from the project sort menu - removes the duplicate setting from General settings Co-authored-by: codex --- apps/web/src/components/Sidebar.tsx | 50 ++++++++++++++++- .../components/settings/SettingsPanels.tsx | 56 +------------------ 2 files changed, 50 insertions(+), 56 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7e433dc4e89..e9b672ee8f2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -112,7 +112,15 @@ import { DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; -import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; +import { + Menu, + MenuGroup, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "./ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { @@ -2257,13 +2265,17 @@ type SortableProjectHandleProps = Pick< function ProjectSortMenu({ projectSortOrder, threadSortOrder, + projectGroupingMode, onProjectSortOrderChange, onThreadSortOrderChange, + onProjectGroupingModeChange, }: { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; + projectGroupingMode: SidebarProjectGroupingMode; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; }) { return ( @@ -2316,6 +2328,30 @@ function ProjectSortMenu({ ))} + + +
+ Group projects +
+ { + if (value === "repository" || value === "repository_path" || value === "separate") { + onProjectGroupingModeChange(value); + } + }} + > + {( + Object.entries(PROJECT_GROUPING_MODE_LABELS) as Array< + [SidebarProjectGroupingMode, string] + > + ).map(([value, label]) => ( + + {label} + + ))} + +
); @@ -2433,6 +2469,7 @@ interface SidebarProjectsContentProps { handleDesktopUpdateButtonClick: () => void; projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; + projectGroupingMode: SidebarProjectGroupingMode; updateSettings: ReturnType["updateSettings"]; openAddProject: () => void; isManualProjectSorting: boolean; @@ -2472,6 +2509,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleDesktopUpdateButtonClick, projectSortOrder, threadSortOrder, + projectGroupingMode, updateSettings, openAddProject, isManualProjectSorting, @@ -2512,6 +2550,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const handleProjectGroupingModeChange = useCallback( + (groupingMode: SidebarProjectGroupingMode) => { + updateSettings({ sidebarProjectGroupingMode: groupingMode }); + }, + [updateSettings], + ); return ( @@ -2570,8 +2614,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); + const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode); const projectGroupingSettings = useSettings((settings) => ({ sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, @@ -3353,6 +3400,7 @@ export default function Sidebar() { handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick} projectSortOrder={sidebarProjectSortOrder} threadSortOrder={sidebarThreadSortOrder} + projectGroupingMode={sidebarProjectGroupingMode} updateSettings={updateSettings} openAddProject={openAddProjectCommandPalette} isManualProjectSorting={isManualProjectSorting} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ece02bf5c6e..f76c69581d8 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -19,10 +19,7 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; -import { - DEFAULT_UNIFIED_SETTINGS, - type SidebarProjectGroupingMode, -} from "@t3tools/contracts/settings"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; @@ -102,12 +99,6 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; -const PROJECT_GROUPING_LABELS: Record = { - repository: "Repository", - repository_path: "Repository path", - separate: "Separate projects", -}; - type InstallProviderSettings = { provider: ProviderKind; title: string; @@ -841,51 +832,6 @@ export function GeneralSettingsPanel() { } /> - - updateSettings({ - sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, - }) - } - /> - ) : null - } - control={ - - } - /> - Date: Thu, 16 Apr 2026 13:05:59 -0700 Subject: [PATCH 4/5] Stop falling back to environment IDs - Return `null` when no saved or runtime label exists - Avoid showing raw environment IDs in the sidebar --- apps/web/src/components/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e9b672ee8f2..1c66af57be0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2798,7 +2798,7 @@ export default function Sidebar() { resolveEnvironmentLabel: (environmentId) => { const rt = savedEnvironmentRuntimeById[environmentId]; const saved = savedEnvironmentRegistry[environmentId]; - return rt?.descriptor?.label ?? saved?.label ?? environmentId; + return rt?.descriptor?.label ?? saved?.label ?? null; }, }); }, [ From 2497af20925fb8dbcb269faf015171d12c872b17 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 14:19:21 -0700 Subject: [PATCH 5/5] Support nested project directories in project keys - derive logical project keys when expanding bootstrap projects - map Claude Opus 4.7 xhigh effort to the SDK max value - normalize repository root assertions for nested worktrees Co-authored-by: codex --- .../Layers/RepositoryIdentityResolver.test.ts | 10 ++++--- .../src/provider/Layers/ClaudeAdapter.test.ts | 8 ++--- .../src/provider/Layers/ClaudeAdapter.ts | 11 +++++-- apps/web/src/components/ChatView.browser.tsx | 29 +++++++++++------- apps/web/src/routes/__root.tsx | 30 ++++++++++++++----- 5 files changed, 60 insertions(+), 28 deletions(-) diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 4cba7f50644..c6ab7b860b5 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -1,3 +1,5 @@ +import { realpathSync } from "node:fs"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { Duration, Effect, FileSystem, Layer } from "effect"; @@ -11,6 +13,8 @@ import { } from "./RepositoryIdentityResolver.ts"; const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); +const normalizeResolvedPath = (value: string) => + normalizePathSeparators(realpathSync.native(value)); const git = (cwd: string, args: ReadonlyArray) => Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); @@ -43,7 +47,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(normalizePathSeparators(identity?.rootPath ?? "")).toBe(normalizePathSeparators(cwd)); + expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(cwd)); expect(identity?.displayName).toBe("t3tools/t3code"); expect(identity?.provider).toBe("github"); expect(identity?.owner).toBe("t3tools"); @@ -68,9 +72,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(normalizePathSeparators(identity?.rootPath ?? "")).toBe( - normalizePathSeparators(repoRoot), - ); + expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(repoRoot)); }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 6f0d4a352f5..1fe080bdaf7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -351,7 +351,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("defaults Claude Opus 4.7 sessions to xhigh effort", () => { + it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -366,14 +366,14 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "xhigh"); + assert.equal(createInput?.options.effort, "max"); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), ); }); - it.effect("forwards xhigh effort for Claude Opus 4.7", () => { + it.effect("maps xhigh effort for Claude Opus 4.7 to the SDK-supported max value", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -391,7 +391,7 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "xhigh"); + assert.equal(createInput?.options.effort, "max"); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index feacfa99ea2..2b3a9faeea0 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -84,6 +84,7 @@ type ClaudeToolResultStreamKind = Extract< RuntimeContentStreamKind, "command_output" | "file_change_output" >; +type ClaudeSdkEffort = NonNullable; type PromptQueueItem = | { @@ -217,11 +218,17 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray function getEffectiveClaudeAgentEffort( effort: ClaudeAgentEffort | null | undefined, -): Exclude | null { +): ClaudeSdkEffort | null { if (!effort) { return null; } - return effort === "ultrathink" ? null : effort; + if (effort === "ultrathink") { + return null; + } + if (effort === "xhigh") { + return "max"; + } + return effort; } function isClaudeInterruptedMessage(message: string): boolean { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 1774a15e3ec..41f627332e3 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -17,12 +17,7 @@ import { OrchestrationSessionStatus, DEFAULT_SERVER_SETTINGS, } from "@t3tools/contracts"; -import { - scopedProjectKey, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, -} from "@t3tools/client-runtime"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; @@ -52,6 +47,7 @@ import { __resetLocalApiForTests } from "../localApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; @@ -78,7 +74,18 @@ const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); const THREAD_KEY = scopedThreadKey(THREAD_REF); 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}$/; const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; -const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJECT_ID)); +const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings( + { + environmentId: LOCAL_ENVIRONMENT_ID, + id: PROJECT_ID, + cwd: "/repo/project", + repositoryIdentity: null, + }, + { + sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingOverrides, + }, +); const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; @@ -1638,12 +1645,12 @@ describe("ChatView timeline estimator parity (full app)", () => { customWsRpcResolver = null; document.body.innerHTML = ""; }); - it("re-expands the bootstrap project using its scoped key", async () => { + it("re-expands the bootstrap project using its logical key", async () => { useUiStateStore.setState({ projectExpandedById: { - [PROJECT_KEY]: false, + [PROJECT_LOGICAL_KEY]: false, }, - projectOrder: [PROJECT_KEY], + projectOrder: [PROJECT_LOGICAL_KEY], threadLastVisitedAtById: {}, }); @@ -1658,7 +1665,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { await vi.waitFor( () => { - expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true); + expect(useUiStateStore.getState().projectExpandedById[PROJECT_LOGICAL_KEY]).toBe(true); }, { timeout: 8_000, interval: 16 }, ); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8c5046af362..b0c0713fe3a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -22,6 +22,11 @@ import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { readLocalApi } from "../localApi"; +import { useSettings } from "../hooks/useSettings"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKeyFromPath, +} from "../logicalProject"; import { getServerConfigUpdatedNotification, ServerConfigUpdatedNotification, @@ -204,6 +209,10 @@ function EventRouter() { const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef(null); const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); @@ -224,14 +233,21 @@ function EventRouter() { if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; } - useUiStateStore - .getState() - .setProjectExpanded( - scopedProjectKey( - scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), - ), - true, + const bootstrapEnvironmentState = + useStore.getState().environmentStateById[payload.environment.environmentId]; + const bootstrapProject = + bootstrapEnvironmentState?.projectById[payload.bootstrapProjectId] ?? null; + const bootstrapProjectKey = + (bootstrapProject + ? deriveLogicalProjectKeyFromSettings(bootstrapProject, projectGroupingSettings) + : null) ?? + (serverConfig?.cwd + ? derivePhysicalProjectKeyFromPath(payload.environment.environmentId, serverConfig.cwd) + : null) ?? + scopedProjectKey( + scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), ); + useUiStateStore.getState().setProjectExpanded(bootstrapProjectKey, true); if (readPathname() !== "/") { return;