From 8bd8ad197cb06af567cd0fa3879b4719f9c0a341 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 02:26:21 +0200 Subject: [PATCH 1/3] refactor(web): move project settings to contextual routes - Replace the project settings listing with standalone contextual project pages - Add project renaming, checkout selection, and updated sidebar and command-palette navigation --- apps/web/src/components/CommandPalette.tsx | 36 +- apps/web/src/components/Sidebar.tsx | 14 +- .../settings/ProjectSettingsPanel.tsx | 1110 +++++++++-------- .../settings/SettingsSidebarNav.tsx | 4 - .../settings/settingsSearch.test.ts | 2 +- .../src/components/settings/settingsSearch.ts | 27 - apps/web/src/routeTree.gen.ts | 64 +- apps/web/src/routes/projects.$projectKey.tsx | 20 + apps/web/src/routes/settings.projects.tsx | 11 - .../routes/settings.projects_.$projectKey.tsx | 12 - 10 files changed, 641 insertions(+), 659 deletions(-) create mode 100644 apps/web/src/routes/projects.$projectKey.tsx delete mode 100644 apps/web/src/routes/settings.projects.tsx delete mode 100644 apps/web/src/routes/settings.projects_.$projectKey.tsx diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d8e03a74b3c..ad909962968 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1506,16 +1506,32 @@ function OpenCommandPaletteDialog(props: { }, }); - actionItems.push({ - kind: "action", - value: "action:project-settings", - searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], - title: "Project settings", - icon: , - run: async () => { - await navigate({ to: "/settings/projects" }); - }, - }); + // There is no projects listing page; the action targets the contextual + // project (active thread/draft, falling back to the first sidebar group). + const contextualProjectGroup = + (contextualProjectRef + ? projectGroupByTargetKey.get( + `${contextualProjectRef.environmentId}:${contextualProjectRef.projectId}`, + ) + : null) ?? + projectGroups[0] ?? + null; + if (contextualProjectGroup) { + actionItems.push({ + kind: "action", + value: "action:project-settings", + searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], + title: "Project settings", + description: contextualProjectGroup.displayName, + icon: , + run: async () => { + await navigate({ + to: "/projects/$projectKey", + params: { projectKey: contextualProjectGroup.projectKey }, + }); + }, + }); + } const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); const sourceSelectionViewValue = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index aee4a04c0a1..75f7c9e58c5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -42,12 +42,12 @@ import { FolderIcon, FolderPlusIcon, GitBranchIcon, - EllipsisIcon, MessageSquareIcon, PinIcon, PlusIcon, SearchIcon, ServerIcon, + SettingsIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -1836,7 +1836,7 @@ export default function Sidebar() { clearSelection(); }, [clearSelection, projectScopeKey]); - const handleProjectActions = useCallback( + const handleProjectSettings = useCallback( (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { event.preventDefault(); event.stopPropagation(); @@ -1845,7 +1845,7 @@ export default function Sidebar() { setOpenMobile(false); } void router.navigate({ - to: "/settings/projects/$projectKey", + to: "/projects/$projectKey", params: { projectKey: projectGroup.projectKey }, }); }, @@ -3295,15 +3295,15 @@ export default function Sidebar() { {project.displayName} ); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 8c2c9f081be..24ba40affeb 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -21,9 +21,9 @@ import type { } from "@t3tools/contracts"; import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useCanGoBack, useNavigate } from "@tanstack/react-router"; import * as Cause from "effect/Cause"; -import { CopyIcon, FolderIcon, PlusIcon, ServerIcon, SettingsIcon, Trash2Icon } from "lucide-react"; +import { CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; @@ -76,9 +76,12 @@ import { type NewProjectScriptInput, type ProjectScriptEditorRequest, } from "../projectScriptEditor"; +import { cn } from "../../lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { SidebarInset } from "../ui/sidebar"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { SettingResetButton, @@ -123,34 +126,139 @@ function memberKey(member: { environmentId: string; id: string }): string { return `${member.environmentId}:${member.id}`; } -export function ProjectSettingsPanel({ - selectedProjectKey, +/** + * Standalone project settings page: the main app sidebar stays, and the page + * brings its own topbar like /usage. Escape returns to the previous view. + */ +export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { + const navigate = useNavigate(); + const canGoBack = useCanGoBack(); + const navigateBackWithinApp = useCallback(() => { + if (canGoBack) { + window.history.back(); + return; + } + void navigate({ to: "/" }); + }, [canGoBack, navigate]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if (event.key !== "Escape") return; + event.preventDefault(); + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement) { + activeElement.blur(); + } + navigateBackWithinApp(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [navigateBackWithinApp]); + + return ( + +
+ {!isElectron && ( +
+ +
+ )} + {isElectron && ( +
+ +
+ )} + +
+
+ ); +} + +function ProjectSettingsBreadcrumb({ + projectKey, + compact = false, }: { - selectedProjectKey: string | null; + projectKey: string; + compact?: boolean; }) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); - const currentHash = useLocation({ select: (location) => location.hash }); + const selected = groups.find((group) => group.projectKey === projectKey) ?? null; - // The index route auto-selects the first project so /settings/projects is - // never a dead end. Hash is preserved for settings-search jumps. - useEffect(() => { - if (selectedProjectKey !== null) return; - const first = groups[0]; - if (!first) return; - void navigate({ - to: "/settings/projects/$projectKey", - params: { projectKey: first.projectKey }, - ...(currentHash ? { hash: currentHash } : {}), - replace: true, - hashScrollIntoView: false, - }); - }, [currentHash, groups, navigate, selectedProjectKey]); + const selectProject = useCallback( + (nextProjectKey: string) => { + void navigate({ + to: "/projects/$projectKey", + params: { projectKey: nextProjectKey }, + replace: true, + hashScrollIntoView: false, + }); + }, + [navigate], + ); - const selected = - selectedProjectKey === null - ? null - : (groups.find((group) => group.projectKey === selectedProjectKey) ?? null); + return ( + + ); +} + +export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { + const groups = useSettingsProjectGroups(); + const navigate = useNavigate(); + + const selected = groups.find((group) => group.projectKey === projectKey) ?? null; // Remember the members of the last rendered group so a grouping-rule change // (which changes the group key) can follow the project to its new group. @@ -163,106 +271,38 @@ export function ProjectSettingsPanel({ }; }, [selected]); - // Recover when the selected key stops matching (regroup, removal, or a - // stale deep link) instead of parking on a dead-end message. + // A grouping-rule change replaces the group key mid-visit; follow the + // project to its new key instead of parking on the not-found state. useEffect(() => { - if (selectedProjectKey === null || selected !== null || groups.length === 0) return; + if (selected !== null) return; const last = lastSelectionRef.current; - const successor = - last?.key === selectedProjectKey - ? (groups.find((group) => - group.memberProjects.some((member) => - last.memberKeys.includes(member.physicalProjectKey), - ), - ) ?? null) - : null; + if (last?.key !== projectKey) return; + const successor = groups.find((group) => + group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), + ); if (successor) { void navigate({ - to: "/settings/projects/$projectKey", + to: "/projects/$projectKey", params: { projectKey: successor.projectKey }, replace: true, hashScrollIntoView: false, }); - } else { - void navigate({ to: "/settings/projects", replace: true, hashScrollIntoView: false }); } - }, [groups, navigate, selected, selectedProjectKey]); + }, [groups, navigate, projectKey, selected]); - const selectProject = useCallback( - (projectKey: string) => { - void navigate({ - to: "/settings/projects/$projectKey", - params: { projectKey }, - replace: true, - hashScrollIntoView: false, - }); - }, - [navigate], - ); - - return ( -
- - {selected ? ( - - ) : ( -
- {groups.length === 0 ? "Add a project from the sidebar to configure it here." : null} -
- )} -
- ); + if (!selected) { + return ( +
+ {groups.length === 0 + ? "Add a project from the sidebar to configure it here." + : "This project is no longer available."} +
+ ); + } + return ; } -function ProjectDetail({ - group, - groups, - onSelectProject, -}: { - group: SidebarProjectSnapshot; - groups: ReadonlyArray; - onSelectProject: (projectKey: string) => void; -}) { +function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const navigate = useNavigate(); const settings = usePrimarySettings(); const updateClientSettings = useUpdateClientSettings(); @@ -307,11 +347,6 @@ function ProjectDetail({ } return counts; }, [threads]); - const groupThreadCount = group.memberProjects.reduce( - (total, member) => total + (threadCountByMember.get(memberKey(member)) ?? 0), - 0, - ); - const reportFailure = useCallback((title: string, result: AtomCommandResult) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); @@ -324,15 +359,15 @@ function ProjectDetail({ ); }, []); - // Group-shared fields (default model, scripts) live on each physical - // project record, so a group-level edit fans out to every member. + // Group-shared fields live on each physical project record, so a + // group-level edit fans out to every member. const updateAllMembers = useCallback( async ( input: Partial<{ + title: string; defaultModelSelection: ModelSelection | null; defaultThreadEnvMode: ThreadEnvMode | null; faviconPath: string | null; - scripts: ReadonlyArray>; }>, failureTitle: string, ): Promise> => { @@ -361,6 +396,19 @@ function ProjectDetail({ [group.memberProjects, reportFailure, updateProject], ); + const renameGroup = useCallback( + async (nextTitle: string) => { + const title = nextTitle.trim(); + if (!title) { + toastManager.add({ type: "warning", title: "Project title cannot be empty" }); + return; + } + if (group.memberProjects.every((member) => member.title === title)) return; + await updateAllMembers({ title }, "Failed to rename project"); + }, + [group.memberProjects, updateAllMembers], + ); + // ----- default model ----- const storedSelection = representative.defaultModelSelection; const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection); @@ -414,14 +462,21 @@ function ProjectDetail({ [updateAllMembers], ); - // ----- scripts ----- - const scripts = representative.scripts; + // ----- checkout selection and scripts ----- + const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(representative.physicalProjectKey); + const selectedCheckout = + group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ?? + representative; + const scripts = selectedCheckout.scripts; const [editorRequest, setEditorRequest] = useState(null); // Script writes replace the whole array, so two overlapping writes computed // from the same snapshot would drop each other's changes. One at a time. const [isSavingScripts, setIsSavingScripts] = useState(false); const savingScriptsRef = useRef(false); - const t3File = useT3ProjectFileState(representative.environmentId, representative.workspaceRoot); + const t3File = useT3ProjectFileState( + selectedCheckout.environmentId, + selectedCheckout.workspaceRoot, + ); // What the "Default" option resolves to while no override is set: the // repo's t3.json value when present, otherwise the global setting. const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode; @@ -456,20 +511,24 @@ function ProjectDetail({ // Captured before the write so a cleared or deleted binding can be // removed from the keybindings config afterwards. const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand); - const updateResult = await updateAllMembers( - { scripts: nextScripts }, - "Failed to save scripts", + const updateResult = mapAtomCommandResult( + await updateProject({ + environmentId: selectedCheckout.environmentId, + input: { projectId: selectedCheckout.id, scripts: nextScripts }, + }), + () => undefined, ); - if (updateResult._tag === "Failure") return updateResult; + if (updateResult._tag === "Failure") { + reportFailure("Failed to save scripts", updateResult); + return updateResult; + } const keybindingRule = decodeProjectScriptKeybindingRule({ keybinding, command: keybindingCommand, }); if (!isElectron) return updateResult; - const environmentIds = [ - ...new Set(group.memberProjects.map((member) => member.environmentId)), - ]; + const environmentIds = [selectedCheckout.environmentId]; const previousTarget = previousKeybinding ? decodeProjectScriptKeybindingRule({ keybinding: previousKeybinding, @@ -512,11 +571,12 @@ function ProjectDetail({ } }, [ - group.memberProjects, keybindings, removeKeybinding, reportFailure, - updateAllMembers, + selectedCheckout.environmentId, + selectedCheckout.id, + updateProject, upsertKeybinding, ], ); @@ -589,26 +649,6 @@ function ProjectDetail({ ); // ----- checkouts ----- - const renameMember = useCallback( - async (member: SidebarProjectGroupMember, nextTitle: string) => { - const title = nextTitle.trim(); - if (!title) { - toastManager.add({ type: "warning", title: "Project title cannot be empty" }); - return; - } - if (title === member.title) return; - const result = mapAtomCommandResult( - await updateProject({ - environmentId: member.environmentId, - input: { projectId: member.id, title }, - }), - () => undefined, - ); - reportFailure("Failed to rename project", result); - }, - [reportFailure, updateProject], - ); - const updateGroupingPreference = useCallback( (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => { const overrideKey = deriveProjectGroupingOverrideKey(member); @@ -689,8 +729,10 @@ function ProjectDetail({ draftStore.clearProjectDraftThreadId(projectRef); } + // The project's settings page just deleted itself; there is no projects + // listing to fall back to, so leave settings entirely. if (isWholeGroup) { - void navigate({ to: "/settings/projects", replace: true }); + void navigate({ to: "/", replace: true }); } }, [ @@ -703,424 +745,404 @@ function ProjectDetail({ ], ); - const repositoryLine = - representative.repositoryIdentity?.displayName ?? - representative.repositoryIdentity?.canonicalKey ?? - "No git remote detected"; - const environmentCount = new Set(group.memberProjects.map((member) => member.environmentId)).size; + const selectedCheckoutThreadCount = threadCountByMember.get(memberKey(selectedCheckout)) ?? 0; + const selectedCheckoutGrouping = + projectGroupingSettings.sidebarProjectGroupingOverrides?.[ + deriveProjectGroupingOverrideKey(selectedCheckout) + ] ?? "inherit"; + const selectedCheckoutLabel = selectedCheckout.environmentLabel ?? "This machine"; return ( - -
- -
-

- {group.displayName} -

-

- {repositoryLine} - {" · "} - {group.memberProjects.length === 1 - ? "1 checkout" - : `${group.memberProjects.length} checkouts`} - {environmentCount > 1 ? ` across ${environmentCount} environments` : ""} - {" · "} - {groupThreadCount === 1 ? "1 thread" : `${groupThreadCount} threads`} -

-
- -
- - - void setFaviconPath(null)} - /> - ) : null - } - control={ -
- - -
- } - /> -
- - - setDefaultModel(null)} + <> + + + { + void renameGroup(event.currentTarget.value); + }} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} /> - ) : null - } - control={ - resolvedSelection && activeEntry ? ( -
- { - setDefaultModel(createModelSelection(instanceId, model)); - }} + } + /> + void setFaviconPath(null)} /> - {}} - modelOptions={resolvedSelection.options ?? []} - allowPromptInjectedEffort={false} - triggerVariant="outline" - triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" - onModelOptionsChange={(nextOptions) => { - setDefaultModel( - createModelSelection( - resolvedSelection.instanceId, - resolvedSelection.model, - nextOptions, - ), - ); - }} + ) : null + } + control={ +
+ +
- ) : ( - No providers available - ) - } - /> - + } + /> + - - setDefaultThreadEnvMode(null)} - /> - ) : null - } - control={ + + setDefaultModel(null)} + /> + ) : null + } + control={ + resolvedSelection && activeEntry ? ( +
+ { + setDefaultModel(createModelSelection(instanceId, model)); + }} + /> + {}} + modelOptions={resolvedSelection.options ?? []} + allowPromptInjectedEffort={false} + triggerVariant="outline" + triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" + onModelOptionsChange={(nextOptions) => { + setDefaultModel( + createModelSelection( + resolvedSelection.instanceId, + resolvedSelection.model, + nextOptions, + ), + ); + }} + /> +
+ ) : ( + No providers available + ) + } + /> + setDefaultThreadEnvMode(null)} + /> + ) : null + } + control={ + + } + /> +
+ + { - if (value === "worktree" || value === "local") { - setDefaultThreadEnvMode(value); - } else if (value === "inherit") { - setDefaultThreadEnvMode(null); - } - }} + value={selectedCheckout.physicalProjectKey} + onValueChange={(value) => setSelectedCheckoutKey(String(value))} > - - - {storedEnvMode === null - ? `Default (${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})` - : resolveEnvModeLabel(storedEnvMode)} - + + {selectedCheckoutLabel} - - Default ({inheritedEnvModeSource}:{" "} - {resolveEnvModeLabel(inheritedEnvMode).toLowerCase()}) - - {resolveEnvModeLabel("worktree")} - {resolveEnvModeLabel("local")} + {group.memberProjects.map((member) => ( + + {member.environmentLabel ?? "This machine"} · {member.workspaceRoot} + + ))} } - /> - - - - setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT }) + > +
+
+ +
+ {selectedCheckoutThreadCount === 1 + ? "1 thread" + : `${selectedCheckoutThreadCount} threads`} +
+
+
+ { + if ( + value === "inherit" || + value === "repository" || + value === "repository_path" || + value === "separate" + ) { + updateGroupingPreference(selectedCheckout, value); + } + }} + > + + + {selectedCheckoutGrouping === "inherit" + ? `Default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})` + : PROJECT_GROUPING_MODE_LABELS[selectedCheckoutGrouping]} + + + + + Use global default + + + {PROJECT_GROUPING_MODE_LABELS.repository} + + + {PROJECT_GROUPING_MODE_LABELS.repository_path} + + + {PROJECT_GROUPING_MODE_LABELS.separate} + + + } - > - - Add action - - } - > - {scripts.length === 0 ? ( -

- No scripts yet. Scripts run in a project terminal from the thread top bar; one script - can run automatically when a worktree is created. -

- ) : ( -
- {scripts.map((script) => { + /> + {group.memberProjects.length > 1 ? ( + void removeMembers([selectedCheckout])} + > + + Remove checkout + + } + /> + ) : null} +
+
+

Actions

+

+ Saved and run only in {selectedCheckoutLabel}. +

+
+ +
+ {scripts.length === 0 ? ( + + ) : ( + scripts.map((script) => { const shortcutLabel = shortcutLabelForCommand( keybindings, commandForProjectScript(script.id), ); return ( -
- - - {script.name} - - {script.runOnWorktreeCreate ? ( - - setup - - ) : null} - {script.previewUrl ? ( - - preview · desktop only + className="group" + title={ + + + {script.name} + {script.runOnWorktreeCreate ? ( + + setup + + ) : null} + {script.previewUrl ? ( + + preview · desktop only + + ) : null} - ) : null} - - {script.command} - - {shortcutLabel ? ( - {shortcutLabel} - ) : null} - -
+ } + description={{script.command}} + control={ + <> + {shortcutLabel ? ( + {shortcutLabel} + ) : null} + + + } + /> ); - })} -
- )} - {t3File.status === "invalid" ? ( - - ) : null} - {importableScripts.length > 0 ? ( - - {importableScripts.map((fileScript) => ( - - ))} -
- } - /> - ) : null} -
- - -
- {group.memberProjects.map((member) => { - const threadCount = threadCountByMember.get(memberKey(member)) ?? 0; - const groupingOverride = - projectGroupingSettings.sidebarProjectGroupingOverrides?.[ - deriveProjectGroupingOverrideKey(member) - ] ?? "inherit"; - return ( -
-
- - - {member.environmentLabel ?? "Current environment"} - - - {threadCount === 1 ? "1 thread" : `${threadCount} threads`} - - {group.memberProjects.length > 1 ? ( + }) + )} + {t3File.status === "invalid" ? ( + + ) : null} + {importableScripts.length > 0 ? ( + + {importableScripts.map((fileScript) => ( - ) : null} -
-
- - - {member.workspaceRoot} - - -
-
- - + ))}
-
- ); - })} -
-
+ } + /> + ) : null} +
- - 1 ? "Remove this project everywhere" : "Remove project" - } - description={ - group.memberProjects.length > 1 - ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.` - : "Deletes the project entry and its threads. Files on disk are not touched." - } - control={ - - } - /> - + + 1 ? "Remove this project everywhere" : "Remove project" + } + description={ + group.memberProjects.length > 1 + ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.` + : "Deletes the project entry and its threads. Files on disk are not touched." + } + control={ + + } + /> + +
- + ); } diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index f310fb5d286..c34ca101653 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -11,7 +11,6 @@ import { ArchiveIcon, ArrowLeftIcon, BotIcon, - FolderIcon, GitBranchIcon, KeyboardIcon, Link2Icon, @@ -49,7 +48,6 @@ const SETTINGS_SECTION_ICONS: Readonly< "/settings/general": Settings2Icon, "/settings/appearance": PaletteIcon, "/settings/keybindings": KeyboardIcon, - "/settings/projects": FolderIcon, "/settings/providers": BotIcon, "/settings/source-control": GitBranchIcon, "/settings/connections": Link2Icon, @@ -279,8 +277,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { )) : SETTINGS_NAV_ITEMS.map((item) => { const Icon = item.icon; - // Prefix match keeps the section active on nested routes - // like /settings/projects/$projectKey. const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`); return ( diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 061a9848f26..a5851b2c714 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -45,7 +45,7 @@ describe("searchSettings", () => { it("matches normalized title substrings", () => { expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); - expect(searchSettings("work").map((item) => item.id)).toEqual(["project-new-thread-workspace"]); + expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]); expect(searchSettings("xyzzy")).toEqual([]); }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index f2cd5ec3419..34fd4602f78 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -2,7 +2,6 @@ export type SettingsPath = | "/settings/general" | "/settings/appearance" | "/settings/keybindings" - | "/settings/projects" | "/settings/providers" | "/settings/source-control" | "/settings/connections" @@ -23,7 +22,6 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/general": "General", "/settings/appearance": "Appearance", "/settings/keybindings": "Keybindings", - "/settings/projects": "Projects", "/settings/providers": "Providers", "/settings/source-control": "Source Control", "/settings/connections": "Connections", @@ -176,31 +174,6 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Keybindings", to: "/settings/keybindings", }, - { - id: "projects", - title: "Projects", - to: "/settings/projects", - }, - { - id: "project-default-model", - title: "Project default model", - to: "/settings/projects", - }, - { - id: "project-new-thread-workspace", - title: "Project new-thread workspace", - to: "/settings/projects", - }, - { - id: "project-scripts", - title: "Project scripts", - to: "/settings/projects", - }, - { - id: "project-checkouts", - title: "Project checkouts", - to: "/settings/projects", - }, { id: "providers", title: "Providers", diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index ac7f4111157..e500a8fcbd7 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -17,15 +17,14 @@ import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' -import { Route as SettingsProjectsRouteImport } from './routes/settings.projects' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' +import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' -import { Route as SettingsProjectsProjectKeyRouteImport } from './routes/settings.projects_.$projectKey' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -68,11 +67,6 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ path: '/providers', getParentRoute: () => SettingsRoute, } as any) -const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ - id: '/projects', - path: '/projects', - getParentRoute: () => SettingsRoute, -} as any) const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ id: '/keybindings', path: '/keybindings', @@ -103,17 +97,16 @@ const SettingsAppearanceRoute = SettingsAppearanceRouteImport.update({ path: '/appearance', getParentRoute: () => SettingsRoute, } as any) +const ProjectsProjectKeyRoute = ProjectsProjectKeyRouteImport.update({ + id: '/projects/$projectKey', + path: '/projects/$projectKey', + getParentRoute: () => rootRouteImport, +} as any) const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ id: '/connect_/callback', path: '/connect/callback', getParentRoute: () => rootRouteImport, } as any) -const SettingsProjectsProjectKeyRoute = - SettingsProjectsProjectKeyRouteImport.update({ - id: '/projects_/$projectKey', - path: '/projects/$projectKey', - getParentRoute: () => SettingsRoute, - } as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -133,18 +126,17 @@ export interface FileRoutesByFullPath { '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute '/connect/callback': typeof ConnectCallbackRoute + '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute - '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute - '/settings/projects/$projectKey': typeof SettingsProjectsProjectKeyRoute } export interface FileRoutesByTo { '/connect': typeof ConnectRoute @@ -152,19 +144,18 @@ export interface FileRoutesByTo { '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute '/connect/callback': typeof ConnectCallbackRoute + '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute - '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute - '/settings/projects/$projectKey': typeof SettingsProjectsProjectKeyRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -174,19 +165,18 @@ export interface FileRoutesById { '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute '/connect_/callback': typeof ConnectCallbackRoute + '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute - '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute - '/settings/projects_/$projectKey': typeof SettingsProjectsProjectKeyRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -197,18 +187,17 @@ export interface FileRouteTypes { | '/settings' | '/usage' | '/connect/callback' + | '/projects/$projectKey' | '/settings/appearance' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' - | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' | '/draft/$draftId' - | '/settings/projects/$projectKey' fileRoutesByTo: FileRoutesByTo to: | '/connect' @@ -216,19 +205,18 @@ export interface FileRouteTypes { | '/settings' | '/usage' | '/connect/callback' + | '/projects/$projectKey' | '/settings/appearance' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' - | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/' | '/$environmentId/$threadId' | '/draft/$draftId' - | '/settings/projects/$projectKey' id: | '__root__' | '/_chat' @@ -237,19 +225,18 @@ export interface FileRouteTypes { | '/settings' | '/usage' | '/connect_/callback' + | '/projects/$projectKey' | '/settings/appearance' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' - | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' - | '/settings/projects_/$projectKey' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -259,6 +246,7 @@ export interface RootRouteChildren { SettingsRoute: typeof SettingsRouteWithChildren UsageRoute: typeof UsageRoute ConnectCallbackRoute: typeof ConnectCallbackRoute + ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute } declare module '@tanstack/react-router' { @@ -319,13 +307,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProvidersRouteImport parentRoute: typeof SettingsRoute } - '/settings/projects': { - id: '/settings/projects' - path: '/projects' - fullPath: '/settings/projects' - preLoaderRoute: typeof SettingsProjectsRouteImport - parentRoute: typeof SettingsRoute - } '/settings/keybindings': { id: '/settings/keybindings' path: '/keybindings' @@ -368,6 +349,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsAppearanceRouteImport parentRoute: typeof SettingsRoute } + '/projects/$projectKey': { + id: '/projects/$projectKey' + path: '/projects/$projectKey' + fullPath: '/projects/$projectKey' + preLoaderRoute: typeof ProjectsProjectKeyRouteImport + parentRoute: typeof rootRouteImport + } '/connect_/callback': { id: '/connect_/callback' path: '/connect/callback' @@ -375,13 +363,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ConnectCallbackRouteImport parentRoute: typeof rootRouteImport } - '/settings/projects_/$projectKey': { - id: '/settings/projects_/$projectKey' - path: '/projects/$projectKey' - fullPath: '/settings/projects/$projectKey' - preLoaderRoute: typeof SettingsProjectsProjectKeyRouteImport - parentRoute: typeof SettingsRoute - } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -420,10 +401,8 @@ interface SettingsRouteChildren { SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute - SettingsProjectsRoute: typeof SettingsProjectsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute - SettingsProjectsProjectKeyRoute: typeof SettingsProjectsProjectKeyRoute } const SettingsRouteChildren: SettingsRouteChildren = { @@ -433,10 +412,8 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, - SettingsProjectsRoute: SettingsProjectsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, - SettingsProjectsProjectKeyRoute: SettingsProjectsProjectKeyRoute, } const SettingsRouteWithChildren = SettingsRoute._addFileChildren( @@ -450,6 +427,7 @@ const rootRouteChildren: RootRouteChildren = { SettingsRoute: SettingsRouteWithChildren, UsageRoute: UsageRoute, ConnectCallbackRoute: ConnectCallbackRoute, + ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/projects.$projectKey.tsx b/apps/web/src/routes/projects.$projectKey.tsx new file mode 100644 index 00000000000..8dcb47b027a --- /dev/null +++ b/apps/web/src/routes/projects.$projectKey.tsx @@ -0,0 +1,20 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { ProjectSettingsPage } from "../components/settings/ProjectSettingsPanel"; + +function ProjectSettingsRoute() { + const { projectKey } = Route.useParams(); + return ; +} + +export const Route = createFileRoute("/projects/$projectKey")({ + beforeLoad: async ({ context }) => { + if ( + context.authGateState.status !== "authenticated" && + context.authGateState.status !== "hosted-static" + ) { + throw redirect({ to: "/pair", replace: true }); + } + }, + component: ProjectSettingsRoute, +}); diff --git a/apps/web/src/routes/settings.projects.tsx b/apps/web/src/routes/settings.projects.tsx deleted file mode 100644 index c8dab231145..00000000000 --- a/apps/web/src/routes/settings.projects.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -import { ProjectSettingsPanel } from "../components/settings/ProjectSettingsPanel"; - -function SettingsProjectsRoute() { - return ; -} - -export const Route = createFileRoute("/settings/projects")({ - component: SettingsProjectsRoute, -}); diff --git a/apps/web/src/routes/settings.projects_.$projectKey.tsx b/apps/web/src/routes/settings.projects_.$projectKey.tsx deleted file mode 100644 index 477ab0c5ebd..00000000000 --- a/apps/web/src/routes/settings.projects_.$projectKey.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -import { ProjectSettingsPanel } from "../components/settings/ProjectSettingsPanel"; - -function SettingsProjectDetailRoute() { - const { projectKey } = Route.useParams(); - return ; -} - -export const Route = createFileRoute("/settings/projects_/$projectKey")({ - component: SettingsProjectDetailRoute, -}); From aaf5b3057e02927c9b412de9968bb1f490c5f400 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 02:42:18 +0200 Subject: [PATCH 2/3] fix(web): improve project settings actions UI - Add a menu for importing checkout actions - Compact action rows to show commands inline --- .../settings/ProjectSettingsPanel.tsx | 165 +++++++++--------- .../components/settings/settingsLayout.tsx | 10 +- apps/web/src/routes/projects.$projectKey.tsx | 7 +- 3 files changed, 90 insertions(+), 92 deletions(-) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 24ba40affeb..a59fabbe80f 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -23,7 +23,7 @@ import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; import * as Cause from "effect/Cause"; -import { CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; +import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; @@ -80,6 +80,15 @@ import { cn } from "../../lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { SidebarInset } from "../ui/sidebar"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -126,10 +135,6 @@ function memberKey(member: { environmentId: string; id: string }): string { return `${member.environmentId}:${member.id}`; } -/** - * Standalone project settings page: the main app sidebar stays, and the page - * brings its own topbar like /usage. Escape returns to the previous view. - */ export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { const navigate = useNavigate(); const canGoBack = useCanGoBack(); @@ -176,7 +181,7 @@ export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, )} > - + )} @@ -185,53 +190,35 @@ export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { ); } -function ProjectSettingsBreadcrumb({ - projectKey, - compact = false, -}: { - projectKey: string; - compact?: boolean; -}) { +function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); const selected = groups.find((group) => group.projectKey === projectKey) ?? null; - const selectProject = useCallback( - (nextProjectKey: string) => { - void navigate({ - to: "/projects/$projectKey", - params: { projectKey: nextProjectKey }, - replace: true, - hashScrollIntoView: false, - }); - }, - [navigate], - ); - return ( ); @@ -1013,30 +998,66 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { } /> ) : null} -
+

Actions

Saved and run only in {selectedCheckoutLabel}.

- +
+ {importableScripts.length > 0 ? ( + + + } + > + Import scripts + + + + + Import from t3.json +

+ Add actions declared by this checkout without editing them first. +

+
+ + {importableScripts.map((fileScript) => ( + void importFileScript(fileScript)} + > + +
+
{fileScript.name}
+
+ {fileScript.command} +
+
+
+ ))} +
+
+ ) : null} + +
{scripts.length === 0 ? ( - +

+ No actions configured for this checkout. +

) : ( scripts.map((script) => { const shortcutLabel = shortcutLabelForCommand( @@ -1046,14 +1067,17 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { return ( - {script.name} + {script.name} + + {script.command} + {script.runOnWorktreeCreate ? ( setup @@ -1066,7 +1090,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ) : null} } - description={{script.command}} control={ <> {shortcutLabel ? ( @@ -1097,28 +1120,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { className="text-warning" /> ) : null} - {importableScripts.length > 0 ? ( - - {importableScripts.map((fileScript) => ( - - ))} -
- } - /> - ) : null} diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 85f568019a1..cf532a77212 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -160,7 +160,7 @@ export function SettingsRow({ ...rowProps }: Omit, "title"> & { title: ReactNode; - description: ReactNode; + description?: ReactNode; status?: ReactNode; resetAction?: ReactNode; control?: ReactNode; @@ -183,9 +183,11 @@ export function SettingsRow({ {resetAction} -

- {description} -

+ {description ? ( +

+ {description} +

+ ) : null} {status ?
{status}
: null} {control ? ( diff --git a/apps/web/src/routes/projects.$projectKey.tsx b/apps/web/src/routes/projects.$projectKey.tsx index 8dcb47b027a..6ae03719c04 100644 --- a/apps/web/src/routes/projects.$projectKey.tsx +++ b/apps/web/src/routes/projects.$projectKey.tsx @@ -2,11 +2,6 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { ProjectSettingsPage } from "../components/settings/ProjectSettingsPanel"; -function ProjectSettingsRoute() { - const { projectKey } = Route.useParams(); - return ; -} - export const Route = createFileRoute("/projects/$projectKey")({ beforeLoad: async ({ context }) => { if ( @@ -16,5 +11,5 @@ export const Route = createFileRoute("/projects/$projectKey")({ throw redirect({ to: "/pair", replace: true }); } }, - component: ProjectSettingsRoute, + component: () => , }); From b4840e83bd9a5e2fef410e258f80116100fe7a24 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 02:52:08 +0200 Subject: [PATCH 3/3] fix(projects): show custom titles and checkout keybindings - Use shared custom titles for repository group labels - Read keybindings from the selected checkout environment - Avoid redundant project renames --- .../settings/ProjectSettingsPanel.tsx | 15 +++++++------ .../src/state/projectGrouping.test.ts | 22 +++++++++++++++++++ .../src/state/projectGrouping.ts | 16 +++++++++++--- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index a59fabbe80f..11cdefaab5f 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -21,6 +21,7 @@ import type { } from "@t3tools/contracts"; import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; import * as Cause from "effect/Cause"; import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; @@ -59,11 +60,7 @@ import { import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; import { useProjects, useThreadShells } from "../../state/entities"; import { projectEnvironment } from "../../state/projects"; -import { - primaryServerKeybindingsAtom, - primaryServerProvidersAtom, - serverEnvironment, -} from "../../state/server"; +import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; @@ -293,7 +290,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const updateClientSettings = useUpdateClientSettings(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const serverProviders = useAtomValue(primaryServerProvidersAtom); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); const threads = useThreadShells(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); @@ -388,10 +384,11 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { toastManager.add({ type: "warning", title: "Project title cannot be empty" }); return; } + if (title === group.displayName) return; if (group.memberProjects.every((member) => member.title === title)) return; await updateAllMembers({ title }, "Failed to rename project"); }, - [group.memberProjects, updateAllMembers], + [group.displayName, group.memberProjects, updateAllMembers], ); // ----- default model ----- @@ -452,6 +449,10 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const selectedCheckout = group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ?? representative; + const selectedServerConfig = useAtomValue( + serverEnvironment.configValueAtom(selectedCheckout.environmentId), + ); + const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; const scripts = selectedCheckout.scripts; const [editorRequest, setEditorRequest] = useState(null); // Script writes replace the whole array, so two overlapping writes computed diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 1c2621b2a83..94d213b257b 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -71,6 +71,28 @@ describe("buildProjectGroups", () => { } }); + it("uses a shared custom title as the repository group's label", () => { + const projects = [ + makeProject("first", "/work/t3code", { title: "Custom project" }), + makeProject("second", "/work/t3code-2", { title: "Custom project" }), + ]; + + expect(buildProjectGroups({ projects, settings: settings("repository") })[0]?.label).toBe( + "Custom project", + ); + }); + + it("keeps the repository label when shared titles match its repository name", () => { + const projects = [ + makeProject("first", "/work/t3code", { title: "t3code" }), + makeProject("second", "/work/t3code-2", { title: "t3code" }), + ]; + + expect(buildProjectGroups({ projects, settings: settings("repository") })[0]?.label).toBe( + "T3 Code", + ); + }); + it("keeps physical clones in separate groups when requested", () => { const projects = [ makeProject("t3code", "/work/t3code"), diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index 8606c4855f2..43785d85dbb 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -169,16 +169,26 @@ export function deriveProjectGroupLabel(input: { readonly representative: Pick; readonly members: ReadonlyArray>; }): string { + const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title)); const sharedDisplayNames = uniqueNonEmptyValues( input.members.map((member) => member.repositoryIdentity?.displayName), ); + const sharedRepositoryNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.name), + ); + const sharedTitle = sharedTitles[0]; + if ( + sharedTitles.length === 1 && + sharedTitle !== undefined && + !sharedDisplayNames.includes(sharedTitle) && + !sharedRepositoryNames.includes(sharedTitle) + ) { + return sharedTitle; + } if (sharedDisplayNames.length === 1) { return sharedDisplayNames[0]!; } - const sharedRepositoryNames = uniqueNonEmptyValues( - input.members.map((member) => member.repositoryIdentity?.name), - ); if (sharedRepositoryNames.length === 1) { return sharedRepositoryNames[0]!; }