authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-10 02:57:39+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-10 02:57:39+02:00
logf21d5e444e9a6b5876253ca8618a4dc8d4f2146c
tree4f76dee16d892e9efbd188358fcde44478dec9cd
parent9a1472d9558ec74b5ed419bd7b87b2aa0e6be1e6
signature Signed by PGP key B5690EEEBB952194

Move project settings to contextual project routes (#5923)


13 files changed, 688 insertions(+), 675 deletions(-)

apps/web/src/components/CommandPalette.tsx+26-10
......@@ -1506,16 +1506,32 @@ function OpenCommandPaletteDialog(props: {
15061506 },
15071507 });
15081508
1509 actionItems.push({
1510 kind: "action",
1511 value: "action:project-settings",
1512 searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"],
1513 title: "Project settings",
1514 icon: <FolderIcon className={ITEM_ICON_CLASS} />,
1515 run: async () => {
1516 await navigate({ to: "/settings/projects" });
1517 },
1518 });
1509 // There is no projects listing page; the action targets the contextual
1510 // project (active thread/draft, falling back to the first sidebar group).
1511 const contextualProjectGroup =
1512 (contextualProjectRef
1513 ? projectGroupByTargetKey.get(
1514 `${contextualProjectRef.environmentId}:${contextualProjectRef.projectId}`,
1515 )
1516 : null) ??
1517 projectGroups[0] ??
1518 null;
1519 if (contextualProjectGroup) {
1520 actionItems.push({
1521 kind: "action",
1522 value: "action:project-settings",
1523 searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"],
1524 title: "Project settings",
1525 description: contextualProjectGroup.displayName,
1526 icon: <FolderIcon className={ITEM_ICON_CLASS} />,
1527 run: async () => {
1528 await navigate({
1529 to: "/projects/$projectKey",
1530 params: { projectKey: contextualProjectGroup.projectKey },
1531 });
1532 },
1533 });
1534 }
15191535
15201536 const rootGroups = buildRootGroups({ actionItems, recentThreadItems });
15211537 const sourceSelectionViewValue =
apps/web/src/components/Sidebar.tsx+7-7
......@@ -42,12 +42,12 @@ import {
4242 FolderIcon,
4343 FolderPlusIcon,
4444 GitBranchIcon,
45 EllipsisIcon,
4645 MessageSquareIcon,
4746 PinIcon,
4847 PlusIcon,
4948 SearchIcon,
5049 ServerIcon,
50 SettingsIcon,
5151 SquarePenIcon,
5252 TerminalIcon,
5353 Undo2Icon,
......@@ -1836,7 +1836,7 @@ export default function Sidebar() {
18361836 clearSelection();
18371837 }, [clearSelection, projectScopeKey]);
18381838
1839 const handleProjectActions = useCallback(
1839 const handleProjectSettings = useCallback(
18401840 (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => {
18411841 event.preventDefault();
18421842 event.stopPropagation();
......@@ -1845,7 +1845,7 @@ export default function Sidebar() {
18451845 setOpenMobile(false);
18461846 }
18471847 void router.navigate({
1848 to: "/settings/projects/$projectKey",
1848 to: "/projects/$projectKey",
18491849 params: { projectKey: projectGroup.projectKey },
18501850 });
18511851 },
......@@ -3295,15 +3295,15 @@ export default function Sidebar() {
32953295 <span className="min-w-0 truncate text-sm">{project.displayName}</span>
32963296 <button
32973297 type="button"
3298 aria-label={`Project actions for ${project.displayName}`}
3299 title={`Project actions for ${project.displayName}`}
3298 aria-label={`Project settings for ${project.displayName}`}
3299 title={`Project settings for ${project.displayName}`}
33003300 className="ml-auto inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-icon-muted outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
33013301 onPointerDown={(event) => event.stopPropagation()}
33023302 onClick={(event) => {
3303 void handleProjectActions(event, project);
3303 void handleProjectSettings(event, project);
33043304 }}
33053305 >
3306 <EllipsisIcon className="size-3.5" />
3306 <SettingsIcon className="size-3.5" />
33073307 </button>
33083308 </MenuRadioItem>
33093309 );
apps/web/src/components/settings/ProjectSettingsPanel.tsx+577-553
......@@ -21,9 +21,10 @@ import type {
2121} from "@t3tools/contracts";
2222import { resolveEnvModeLabel } from "../BranchToolbar.logic";
2323import { createModelSelection } from "@t3tools/shared/model";
24import { useLocation, useNavigate } from "@tanstack/react-router";
24import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
25import { useCanGoBack, useNavigate } from "@tanstack/react-router";
2526import * as Cause from "effect/Cause";
26import { CopyIcon, FolderIcon, PlusIcon, ServerIcon, SettingsIcon, Trash2Icon } from "lucide-react";
27import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react";
2728import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2829
2930import { useComposerDraftStore } from "../../composerDraftStore";
......@@ -59,11 +60,7 @@ import {
5960import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
6061import { useProjects, useThreadShells } from "../../state/entities";
6162import { projectEnvironment } from "../../state/projects";
62import {
63 primaryServerKeybindingsAtom,
64 primaryServerProvidersAtom,
65 serverEnvironment,
66} from "../../state/server";
63import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server";
6764import { useAtomCommand } from "../../state/use-atom-command";
6865import { ProviderModelPicker } from "../chat/ProviderModelPicker";
6966import { TraitsPicker } from "../chat/TraitsPicker";
......@@ -76,9 +73,21 @@ import {
7673 type NewProjectScriptInput,
7774 type ProjectScriptEditorRequest,
7875} from "../projectScriptEditor";
76import { cn } from "../../lib/utils";
77import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar";
7978import { Button } from "../ui/button";
8079import { Input } from "../ui/input";
80import {
81 Menu,
82 MenuGroup,
83 MenuGroupLabel,
84 MenuItem,
85 MenuPopup,
86 MenuSeparator,
87 MenuTrigger,
88} from "../ui/menu";
8189import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
90import { SidebarInset } from "../ui/sidebar";
8291import { stackedThreadToast, toastManager } from "../ui/toast";
8392import {
8493 SettingResetButton,
......@@ -123,34 +132,115 @@ function memberKey(member: { environmentId: string; id: string }): string {
123132 return `${member.environmentId}:${member.id}`;
124133}
125134
126export function ProjectSettingsPanel({
127 selectedProjectKey,
128}: {
129 selectedProjectKey: string | null;
130}) {
131 const groups = useSettingsProjectGroups();
135export function ProjectSettingsPage({ projectKey }: { projectKey: string }) {
132136 const navigate = useNavigate();
133 const currentHash = useLocation({ select: (location) => location.hash });
137 const canGoBack = useCanGoBack();
138 const navigateBackWithinApp = useCallback(() => {
139 if (canGoBack) {
140 window.history.back();
141 return;
142 }
143 void navigate({ to: "/" });
144 }, [canGoBack, navigate]);
134145
135 // The index route auto-selects the first project so /settings/projects is
136 // never a dead end. Hash is preserved for settings-search jumps.
137146 useEffect(() => {
138 if (selectedProjectKey !== null) return;
139 const first = groups[0];
140 if (!first) return;
141 void navigate({
142 to: "/settings/projects/$projectKey",
143 params: { projectKey: first.projectKey },
144 ...(currentHash ? { hash: currentHash } : {}),
145 replace: true,
146 hashScrollIntoView: false,
147 });
148 }, [currentHash, groups, navigate, selectedProjectKey]);
147 const onKeyDown = (event: KeyboardEvent) => {
148 if (event.defaultPrevented) return;
149 if (event.key !== "Escape") return;
150 event.preventDefault();
151 const activeElement = document.activeElement;
152 if (activeElement instanceof HTMLElement) {
153 activeElement.blur();
154 }
155 navigateBackWithinApp();
156 };
157 window.addEventListener("keydown", onKeyDown);
158 return () => window.removeEventListener("keydown", onKeyDown);
159 }, [navigateBackWithinApp]);
149160
150 const selected =
151 selectedProjectKey === null
152 ? null
153 : (groups.find((group) => group.projectKey === selectedProjectKey) ?? null);
161 return (
162 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
163 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
164 {!isElectron && (
165 <header
166 className={cn(
167 "workspace-topbar px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
168 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
169 )}
170 >
171 <ProjectSettingsBreadcrumb projectKey={projectKey} />
172 </header>
173 )}
174 {isElectron && (
175 <div
176 className={cn(
177 "drag-region flex h-[52px] shrink-0 items-center px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
178 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
179 )}
180 >
181 <ProjectSettingsBreadcrumb projectKey={projectKey} />
182 </div>
183 )}
184 <ProjectSettingsPanel projectKey={projectKey} />
185 </div>
186 </SidebarInset>
187 );
188}
189
190function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) {
191 const groups = useSettingsProjectGroups();
192 const navigate = useNavigate();
193 const selected = groups.find((group) => group.projectKey === projectKey) ?? null;
194
195 return (
196 <nav
197 aria-label="Project settings breadcrumb"
198 className="flex min-w-0 items-center gap-2 overflow-hidden [-webkit-app-region:no-drag] sm:gap-3"
199 >
200 <span className="shrink-0 text-sm font-medium text-muted-foreground">Projects</span>
201 <span aria-hidden="true" className="shrink-0 text-icon-muted">
202 /
203 </span>
204 {selected ? (
205 <Select
206 value={selected.projectKey}
207 onValueChange={(value) =>
208 void navigate({
209 to: "/projects/$projectKey",
210 params: { projectKey: String(value) },
211 replace: true,
212 hashScrollIntoView: false,
213 })
214 }
215 >
216 <SelectTrigger
217 aria-label="Switch project"
218 className="h-auto min-h-0 min-w-0 max-w-64 gap-1 border-0 px-0 py-0 text-sm font-medium text-foreground sm:h-auto sm:min-h-0 sm:text-sm"
219 size="xs"
220 variant="ghost"
221 >
222 <SelectValue>{selected.displayName}</SelectValue>
223 </SelectTrigger>
224 <SelectPopup align="start" alignItemWithTrigger={false}>
225 {groups.map((candidate) => (
226 <SelectItem key={candidate.projectKey} hideIndicator value={candidate.projectKey}>
227 {candidate.displayName}
228 </SelectItem>
229 ))}
230 </SelectPopup>
231 </Select>
232 ) : (
233 <span className="truncate text-sm text-muted-foreground">Unavailable project</span>
234 )}
235 </nav>
236 );
237}
238
239export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {
240 const groups = useSettingsProjectGroups();
241 const navigate = useNavigate();
242
243 const selected = groups.find((group) => group.projectKey === projectKey) ?? null;
154244
155245 // Remember the members of the last rendered group so a grouping-rule change
156246 // (which changes the group key) can follow the project to its new group.
......@@ -163,112 +253,43 @@ export function ProjectSettingsPanel({
163253 };
164254 }, [selected]);
165255
166 // Recover when the selected key stops matching (regroup, removal, or a
167 // stale deep link) instead of parking on a dead-end message.
256 // A grouping-rule change replaces the group key mid-visit; follow the
257 // project to its new key instead of parking on the not-found state.
168258 useEffect(() => {
169 if (selectedProjectKey === null || selected !== null || groups.length === 0) return;
259 if (selected !== null) return;
170260 const last = lastSelectionRef.current;
171 const successor =
172 last?.key === selectedProjectKey
173 ? (groups.find((group) =>
174 group.memberProjects.some((member) =>
175 last.memberKeys.includes(member.physicalProjectKey),
176 ),
177 ) ?? null)
178 : null;
261 if (last?.key !== projectKey) return;
262 const successor = groups.find((group) =>
263 group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)),
264 );
179265 if (successor) {
180266 void navigate({
181 to: "/settings/projects/$projectKey",
267 to: "/projects/$projectKey",
182268 params: { projectKey: successor.projectKey },
183269 replace: true,
184270 hashScrollIntoView: false,
185271 });
186 } else {
187 void navigate({ to: "/settings/projects", replace: true, hashScrollIntoView: false });
188272 }
189 }, [groups, navigate, selected, selectedProjectKey]);
273 }, [groups, navigate, projectKey, selected]);
190274
191 const selectProject = useCallback(
192 (projectKey: string) => {
193 void navigate({
194 to: "/settings/projects/$projectKey",
195 params: { projectKey },
196 replace: true,
197 hashScrollIntoView: false,
198 });
199 },
200 [navigate],
201 );
202
203 return (
204 <div className="flex min-h-0 flex-1">
205 <nav
206 aria-label="Projects"
207 className="w-60 shrink-0 space-y-0.5 overflow-y-auto border-e border-border/40 p-3 pt-10 max-sm:hidden sm:pt-12"
208 >
209 {groups.map((group) => {
210 const isActive = group.projectKey === selected?.projectKey;
211 return (
212 <button
213 key={group.projectKey}
214 type="button"
215 onClick={() => selectProject(group.projectKey)}
216 className={`flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring ${
217 isActive
218 ? "bg-accent text-foreground"
219 : "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
220 }`}
221 >
222 <ProjectFavicon
223 environmentId={group.environmentId}
224 cwd={group.workspaceRoot}
225 faviconPath={group.faviconPath}
226 className="size-4 shrink-0"
227 />
228 <span className="min-w-0 flex-1 truncate">{group.displayName}</span>
229 {group.groupedProjectCount > 1 ? (
230 <span className="shrink-0 text-xs text-muted-foreground/70">
231 {group.groupedProjectCount}
232 </span>
233 ) : null}
234 </button>
235 );
236 })}
237 {groups.length === 0 ? (
238 <p className="px-2 py-4 text-sm text-muted-foreground">No projects yet.</p>
239 ) : null}
240 </nav>
241 {selected ? (
242 <ProjectDetail
243 key={selected.projectKey}
244 group={selected}
245 groups={groups}
246 onSelectProject={selectProject}
247 />
248 ) : (
249 <div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground">
250 {groups.length === 0 ? "Add a project from the sidebar to configure it here." : null}
251 </div>
252 )}
253 </div>
254 );
275 if (!selected) {
276 return (
277 <div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground">
278 {groups.length === 0
279 ? "Add a project from the sidebar to configure it here."
280 : "This project is no longer available."}
281 </div>
282 );
283 }
284 return <ProjectDetail key={selected.projectKey} group={selected} />;
255285}
256286
257function ProjectDetail({
258 group,
259 groups,
260 onSelectProject,
261}: {
262 group: SidebarProjectSnapshot;
263 groups: ReadonlyArray<SidebarProjectSnapshot>;
264 onSelectProject: (projectKey: string) => void;
265}) {
287function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
266288 const navigate = useNavigate();
267289 const settings = usePrimarySettings();
268290 const updateClientSettings = useUpdateClientSettings();
269291 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
270292 const serverProviders = useAtomValue(primaryServerProvidersAtom);
271 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
272293 const threads = useThreadShells();
273294 const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
274295 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
......@@ -307,11 +328,6 @@ function ProjectDetail({
307328 }
308329 return counts;
309330 }, [threads]);
310 const groupThreadCount = group.memberProjects.reduce(
311 (total, member) => total + (threadCountByMember.get(memberKey(member)) ?? 0),
312 0,
313 );
314
315331 const reportFailure = useCallback((title: string, result: AtomCommandResult<void, unknown>) => {
316332 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
317333 const error = squashAtomCommandFailure(result);
......@@ -324,15 +340,15 @@ function ProjectDetail({
324340 );
325341 }, []);
326342
327 // Group-shared fields (default model, scripts) live on each physical
328 // project record, so a group-level edit fans out to every member.
343 // Group-shared fields live on each physical project record, so a
344 // group-level edit fans out to every member.
329345 const updateAllMembers = useCallback(
330346 async (
331347 input: Partial<{
348 title: string;
332349 defaultModelSelection: ModelSelection | null;
333350 defaultThreadEnvMode: ThreadEnvMode | null;
334351 faviconPath: string | null;
335 scripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>;
336352 }>,
337353 failureTitle: string,
338354 ): Promise<AtomCommandResult<void, unknown>> => {
......@@ -361,6 +377,20 @@ function ProjectDetail({
361377 [group.memberProjects, reportFailure, updateProject],
362378 );
363379
380 const renameGroup = useCallback(
381 async (nextTitle: string) => {
382 const title = nextTitle.trim();
383 if (!title) {
384 toastManager.add({ type: "warning", title: "Project title cannot be empty" });
385 return;
386 }
387 if (title === group.displayName) return;
388 if (group.memberProjects.every((member) => member.title === title)) return;
389 await updateAllMembers({ title }, "Failed to rename project");
390 },
391 [group.displayName, group.memberProjects, updateAllMembers],
392 );
393
364394 // ----- default model -----
365395 const storedSelection = representative.defaultModelSelection;
366396 const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection);
......@@ -414,14 +444,25 @@ function ProjectDetail({
414444 [updateAllMembers],
415445 );
416446
417 // ----- scripts -----
418 const scripts = representative.scripts;
447 // ----- checkout selection and scripts -----
448 const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(representative.physicalProjectKey);
449 const selectedCheckout =
450 group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ??
451 representative;
452 const selectedServerConfig = useAtomValue(
453 serverEnvironment.configValueAtom(selectedCheckout.environmentId),
454 );
455 const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;
456 const scripts = selectedCheckout.scripts;
419457 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
420458 // Script writes replace the whole array, so two overlapping writes computed
421459 // from the same snapshot would drop each other's changes. One at a time.
422460 const [isSavingScripts, setIsSavingScripts] = useState(false);
423461 const savingScriptsRef = useRef(false);
424 const t3File = useT3ProjectFileState(representative.environmentId, representative.workspaceRoot);
462 const t3File = useT3ProjectFileState(
463 selectedCheckout.environmentId,
464 selectedCheckout.workspaceRoot,
465 );
425466 // What the "Default" option resolves to while no override is set: the
426467 // repo's t3.json value when present, otherwise the global setting.
427468 const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode;
......@@ -456,20 +497,24 @@ function ProjectDetail({
456497 // Captured before the write so a cleared or deleted binding can be
457498 // removed from the keybindings config afterwards.
458499 const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand);
459 const updateResult = await updateAllMembers(
460 { scripts: nextScripts },
461 "Failed to save scripts",
500 const updateResult = mapAtomCommandResult(
501 await updateProject({
502 environmentId: selectedCheckout.environmentId,
503 input: { projectId: selectedCheckout.id, scripts: nextScripts },
504 }),
505 () => undefined,
462506 );
463 if (updateResult._tag === "Failure") return updateResult;
507 if (updateResult._tag === "Failure") {
508 reportFailure("Failed to save scripts", updateResult);
509 return updateResult;
510 }
464511
465512 const keybindingRule = decodeProjectScriptKeybindingRule({
466513 keybinding,
467514 command: keybindingCommand,
468515 });
469516 if (!isElectron) return updateResult;
470 const environmentIds = [
471 ...new Set(group.memberProjects.map((member) => member.environmentId)),
472 ];
517 const environmentIds = [selectedCheckout.environmentId];
473518 const previousTarget = previousKeybinding
474519 ? decodeProjectScriptKeybindingRule({
475520 keybinding: previousKeybinding,
......@@ -512,11 +557,12 @@ function ProjectDetail({
512557 }
513558 },
514559 [
515 group.memberProjects,
516560 keybindings,
517561 removeKeybinding,
518562 reportFailure,
519 updateAllMembers,
563 selectedCheckout.environmentId,
564 selectedCheckout.id,
565 updateProject,
520566 upsertKeybinding,
521567 ],
522568 );
......@@ -589,26 +635,6 @@ function ProjectDetail({
589635 );
590636
591637 // ----- checkouts -----
592 const renameMember = useCallback(
593 async (member: SidebarProjectGroupMember, nextTitle: string) => {
594 const title = nextTitle.trim();
595 if (!title) {
596 toastManager.add({ type: "warning", title: "Project title cannot be empty" });
597 return;
598 }
599 if (title === member.title) return;
600 const result = mapAtomCommandResult(
601 await updateProject({
602 environmentId: member.environmentId,
603 input: { projectId: member.id, title },
604 }),
605 () => undefined,
606 );
607 reportFailure("Failed to rename project", result);
608 },
609 [reportFailure, updateProject],
610 );
611
612638 const updateGroupingPreference = useCallback(
613639 (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => {
614640 const overrideKey = deriveProjectGroupingOverrideKey(member);
......@@ -689,8 +715,10 @@ function ProjectDetail({
689715 draftStore.clearProjectDraftThreadId(projectRef);
690716 }
691717
718 // The project's settings page just deleted itself; there is no projects
719 // listing to fall back to, so leave settings entirely.
692720 if (isWholeGroup) {
693 void navigate({ to: "/settings/projects", replace: true });
721 void navigate({ to: "/", replace: true });
694722 }
695723 },
696724 [
......@@ -703,424 +731,420 @@ function ProjectDetail({
703731 ],
704732 );
705733
706 const repositoryLine =
707 representative.repositoryIdentity?.displayName ??
708 representative.repositoryIdentity?.canonicalKey ??
709 "No git remote detected";
710 const environmentCount = new Set(group.memberProjects.map((member) => member.environmentId)).size;
734 const selectedCheckoutThreadCount = threadCountByMember.get(memberKey(selectedCheckout)) ?? 0;
735 const selectedCheckoutGrouping =
736 projectGroupingSettings.sidebarProjectGroupingOverrides?.[
737 deriveProjectGroupingOverrideKey(selectedCheckout)
738 ] ?? "inherit";
739 const selectedCheckoutLabel = selectedCheckout.environmentLabel ?? "This machine";
711740
712741 return (
713 <SettingsPageContainer className="gap-10">
714 <div className="flex items-center gap-3 px-3 sm:px-4">
715 <ProjectFavicon
716 environmentId={group.environmentId}
717 cwd={group.workspaceRoot}
718 faviconPath={group.faviconPath}
719 className="size-8 shrink-0"
720 />
721 <div className="min-w-0 flex-1">
722 <h1 className="truncate text-xl font-semibold tracking-[-0.02em] text-foreground">
723 {group.displayName}
724 </h1>
725 <p className="truncate text-[13px] text-muted-foreground">
726 {repositoryLine}
727 {" · "}
728 {group.memberProjects.length === 1
729 ? "1 checkout"
730 : `${group.memberProjects.length} checkouts`}
731 {environmentCount > 1 ? ` across ${environmentCount} environments` : ""}
732 {" · "}
733 {groupThreadCount === 1 ? "1 thread" : `${groupThreadCount} threads`}
734 </p>
735 </div>
736 <Select value={group.projectKey} onValueChange={(value) => onSelectProject(String(value))}>
737 <SelectTrigger className="sm:hidden" aria-label="Switch project">
738 <SelectValue>{group.displayName}</SelectValue>
739 </SelectTrigger>
740 <SelectPopup align="end" alignItemWithTrigger={false}>
741 {groups.map((candidate) => (
742 <SelectItem key={candidate.projectKey} hideIndicator value={candidate.projectKey}>
743 {candidate.displayName}
744 </SelectItem>
745 ))}
746 </SelectPopup>
747 </Select>
748 </div>
749
750 <SettingsSection title="Appearance">
751 <SettingsRow
752 id="project-favicon"
753 title="Project icon"
754 description={faviconPath ?? "Automatic"}
755 resetAction={
756 faviconPath !== null ? (
757 <SettingResetButton
758 label="project icon"
759 disabled={isSavingFavicon}
760 onClick={() => void setFaviconPath(null)}
761 />
762 ) : null
763 }
764 control={
765 <div className="flex items-center gap-2">
766 <ProjectFavicon
767 environmentId={representative.environmentId}
768 cwd={representative.workspaceRoot}
769 faviconPath={faviconPath}
770 className="size-6"
771 />
772 <Button
773 size="xs"
774 variant="outline"
775 type="button"
776 aria-label="Choose a project icon file"
777 disabled={isSavingFavicon}
778 onClick={() => setFaviconPickerOpen(true)}
779 >
780 Choose file
781 </Button>
782 </div>
783 }
784 />
785 </SettingsSection>
786
787 <SettingsSection title="Model">
788 <SettingsRow
789 id="project-default-model"
790 title="Default model"
791 description="New threads in this project start with this model. Applies to every checkout in this group."
792 resetAction={
793 storedSelection !== null ? (
794 <SettingResetButton
795 label="project default model"
796 onClick={() => setDefaultModel(null)}
742 <>
743 <SettingsPageContainer>
744 <SettingsSection title="Project">
745 <SettingsRow
746 title="Name"
747 description="The shared name for this project group in the sidebar and thread lists."
748 control={
749 <Input
750 key={`${group.projectKey}:${group.displayName}`}
751 className="w-full sm:w-64"
752 aria-label="Project name"
753 defaultValue={group.displayName}
754 onBlur={(event) => {
755 void renameGroup(event.currentTarget.value);
756 }}
757 onKeyDown={(event) => {
758 if (event.key === "Enter") event.currentTarget.blur();
759 }}
797760 />
798 ) : null
799 }
800 control={
801 resolvedSelection && activeEntry ? (
802 <div className="flex flex-wrap items-center justify-end gap-1.5">
803 <ProviderModelPicker
804 activeInstanceId={resolvedSelection.instanceId}
805 model={resolvedSelection.model}
806 lockedProvider={null}
807 instanceEntries={instanceEntries}
808 modelOptionsByInstance={modelOptionsByInstance}
809 triggerVariant="outline"
810 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
811 onInstanceModelChange={(instanceId, model) => {
812 setDefaultModel(createModelSelection(instanceId, model));
813 }}
761 }
762 />
763 <SettingsRow
764 title="Project icon"
765 description={faviconPath ?? "Automatic"}
766 resetAction={
767 faviconPath !== null ? (
768 <SettingResetButton
769 label="project icon"
770 disabled={isSavingFavicon}
771 onClick={() => void setFaviconPath(null)}
814772 />
815 <TraitsPicker
816 provider={activeEntry.driverKind as ProviderDriverKind}
817 models={activeEntry.models}
818 model={resolvedSelection.model}
819 prompt=""
820 onPromptChange={() => {}}
821 modelOptions={resolvedSelection.options ?? []}
822 allowPromptInjectedEffort={false}
823 triggerVariant="outline"
824 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
825 onModelOptionsChange={(nextOptions) => {
826 setDefaultModel(
827 createModelSelection(
828 resolvedSelection.instanceId,
829 resolvedSelection.model,
830 nextOptions,
831 ),
832 );
833 }}
773 ) : null
774 }
775 control={
776 <div className="flex items-center gap-2">
777 <ProjectFavicon
778 environmentId={representative.environmentId}
779 cwd={representative.workspaceRoot}
780 faviconPath={faviconPath}
781 className="size-6"
834782 />
783 <Button
784 size="xs"
785 variant="outline"
786 type="button"
787 aria-label="Choose a project icon file"
788 disabled={isSavingFavicon}
789 onClick={() => setFaviconPickerOpen(true)}
790 >
791 Choose file
792 </Button>
835793 </div>
836 ) : (
837 <span className="text-sm text-muted-foreground">No providers available</span>
838 )
839 }
840 />
841 </SettingsSection>
794 }
795 />
796 </SettingsSection>
842797
843 <SettingsSection title="New threads">
844 <SettingsRow
845 id="project-new-thread-workspace"
846 title="Workspace"
847 description="Where new threads in this project start. Overrides t3.json and the global default; applies to every checkout in this group."
848 resetAction={
849 storedEnvMode !== null ? (
850 <SettingResetButton
851 label="project workspace default"
852 onClick={() => setDefaultThreadEnvMode(null)}
853 />
854 ) : null
855 }
856 control={
798 <SettingsSection title="New threads">
799 <SettingsRow
800 title="Model"
801 description="New threads in this project start with this model. Applies to every checkout in this group."
802 resetAction={
803 storedSelection !== null ? (
804 <SettingResetButton
805 label="project default model"
806 onClick={() => setDefaultModel(null)}
807 />
808 ) : null
809 }
810 control={
811 resolvedSelection && activeEntry ? (
812 <div className="flex flex-wrap items-center justify-end gap-1.5">
813 <ProviderModelPicker
814 activeInstanceId={resolvedSelection.instanceId}
815 model={resolvedSelection.model}
816 lockedProvider={null}
817 instanceEntries={instanceEntries}
818 modelOptionsByInstance={modelOptionsByInstance}
819 triggerVariant="outline"
820 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
821 onInstanceModelChange={(instanceId, model) => {
822 setDefaultModel(createModelSelection(instanceId, model));
823 }}
824 />
825 <TraitsPicker
826 provider={activeEntry.driverKind as ProviderDriverKind}
827 models={activeEntry.models}
828 model={resolvedSelection.model}
829 prompt=""
830 onPromptChange={() => {}}
831 modelOptions={resolvedSelection.options ?? []}
832 allowPromptInjectedEffort={false}
833 triggerVariant="outline"
834 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
835 onModelOptionsChange={(nextOptions) => {
836 setDefaultModel(
837 createModelSelection(
838 resolvedSelection.instanceId,
839 resolvedSelection.model,
840 nextOptions,
841 ),
842 );
843 }}
844 />
845 </div>
846 ) : (
847 <span className="text-sm text-muted-foreground">No providers available</span>
848 )
849 }
850 />
851 <SettingsRow
852 title="Workspace"
853 description="Where new threads in this project start. Overrides t3.json and the global default; applies to every checkout in this group."
854 resetAction={
855 storedEnvMode !== null ? (
856 <SettingResetButton
857 label="project workspace default"
858 onClick={() => setDefaultThreadEnvMode(null)}
859 />
860 ) : null
861 }
862 control={
863 <Select
864 value={storedEnvMode ?? "inherit"}
865 onValueChange={(value) => {
866 if (value === "worktree" || value === "local") {
867 setDefaultThreadEnvMode(value);
868 } else if (value === "inherit") {
869 setDefaultThreadEnvMode(null);
870 }
871 }}
872 >
873 <SelectTrigger aria-label="New-thread workspace">
874 <SelectValue>
875 {storedEnvMode === null
876 ? group.memberProjects.length > 1
877 ? "Default (per checkout)"
878 : `Default (${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})`
879 : resolveEnvModeLabel(storedEnvMode)}
880 </SelectValue>
881 </SelectTrigger>
882 <SelectPopup align="end" alignItemWithTrigger={false}>
883 <SelectItem value="inherit">
884 {group.memberProjects.length > 1
885 ? "Default (each checkout's t3.json or global setting)"
886 : `Default (${inheritedEnvModeSource}: ${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})`}
887 </SelectItem>
888 <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem>
889 <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem>
890 </SelectPopup>
891 </Select>
892 }
893 />
894 </SettingsSection>
895
896 <SettingsSection
897 title="Checkout"
898 headerAction={
857899 <Select
858 value={storedEnvMode ?? "inherit"}
859 onValueChange={(value) => {
860 if (value === "worktree" || value === "local") {
861 setDefaultThreadEnvMode(value);
862 } else if (value === "inherit") {
863 setDefaultThreadEnvMode(null);
864 }
865 }}
900 value={selectedCheckout.physicalProjectKey}
901 onValueChange={(value) => setSelectedCheckoutKey(String(value))}
866902 >
867 <SelectTrigger aria-label="New-thread workspace">
868 <SelectValue>
869 {storedEnvMode === null
870 ? `Default (${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})`
871 : resolveEnvModeLabel(storedEnvMode)}
872 </SelectValue>
903 <SelectTrigger className="max-w-64" aria-label="Selected checkout">
904 <SelectValue>{selectedCheckoutLabel}</SelectValue>
873905 </SelectTrigger>
874906 <SelectPopup align="end" alignItemWithTrigger={false}>
875 <SelectItem value="inherit">
876 Default ({inheritedEnvModeSource}:{" "}
877 {resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})
878 </SelectItem>
879 <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem>
880 <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem>
907 {group.memberProjects.map((member) => (
908 <SelectItem
909 key={member.physicalProjectKey}
910 hideIndicator
911 value={member.physicalProjectKey}
912 >
913 {member.environmentLabel ?? "This machine"} · {member.workspaceRoot}
914 </SelectItem>
915 ))}
881916 </SelectPopup>
882917 </Select>
883918 }
884 />
885 </SettingsSection>
886
887 <SettingsSection
888 id="project-scripts"
889 title="Scripts"
890 headerAction={
891 <Button
892 size="xs"
893 variant="outline"
894 disabled={isSavingScripts}
895 onClick={() =>
896 setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })
919 >
920 <div className="px-3 py-2 sm:px-4">
921 <div className="flex min-w-0 items-center rounded-lg bg-muted/30 p-1 text-base text-muted-foreground sm:text-sm">
922 <button
923 aria-label="Copy checkout path"
924 className="group flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-left outline-none hover:bg-accent/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
925 title="Copy path"
926 type="button"
927 onClick={() =>
928 copyPathToClipboard(selectedCheckout.workspaceRoot, {
929 path: selectedCheckout.workspaceRoot,
930 })
931 }
932 >
933 <code className="min-w-0 flex-1 truncate font-mono">
934 {selectedCheckout.workspaceRoot}
935 </code>
936 <CopyIcon className="size-4 shrink-0 opacity-60 group-hover:opacity-100" />
937 </button>
938 <div className="shrink-0 border-l border-border/60 px-2 tabular-nums">
939 {selectedCheckoutThreadCount === 1
940 ? "1 thread"
941 : `${selectedCheckoutThreadCount} threads`}
942 </div>
943 </div>
944 </div>
945 <SettingsRow
946 title="Project grouping"
947 description="How this checkout joins project groups in the sidebar. Changing it can move you to a different project group."
948 control={
949 <Select
950 value={selectedCheckoutGrouping}
951 onValueChange={(value) => {
952 if (
953 value === "inherit" ||
954 value === "repository" ||
955 value === "repository_path" ||
956 value === "separate"
957 ) {
958 updateGroupingPreference(selectedCheckout, value);
959 }
960 }}
961 >
962 <SelectTrigger aria-label={`Grouping rule for ${selectedCheckoutLabel}`}>
963 <SelectValue>
964 {selectedCheckoutGrouping === "inherit"
965 ? `Default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})`
966 : PROJECT_GROUPING_MODE_LABELS[selectedCheckoutGrouping]}
967 </SelectValue>
968 </SelectTrigger>
969 <SelectPopup align="end" alignItemWithTrigger={false}>
970 <SelectItem hideIndicator value="inherit">
971 Use global default
972 </SelectItem>
973 <SelectItem hideIndicator value="repository">
974 {PROJECT_GROUPING_MODE_LABELS.repository}
975 </SelectItem>
976 <SelectItem hideIndicator value="repository_path">
977 {PROJECT_GROUPING_MODE_LABELS.repository_path}
978 </SelectItem>
979 <SelectItem hideIndicator value="separate">
980 {PROJECT_GROUPING_MODE_LABELS.separate}
981 </SelectItem>
982 </SelectPopup>
983 </Select>
897984 }
898 >
899 <PlusIcon className="size-3.5" />
900 Add action
901 </Button>
902 }
903 >
904 {scripts.length === 0 ? (
905 <p className="px-3 py-2 text-sm text-muted-foreground sm:px-4">
906 No scripts yet. Scripts run in a project terminal from the thread top bar; one script
907 can run automatically when a worktree is created.
908 </p>
909 ) : (
910 <div className="space-y-0.5">
911 {scripts.map((script) => {
985 />
986 {group.memberProjects.length > 1 ? (
987 <SettingsRow
988 title="Remove checkout"
989 description="Removes this checkout and its threads from the project group. Files on disk are not touched."
990 control={
991 <Button
992 size="xs"
993 variant="destructive-outline"
994 onClick={() => void removeMembers([selectedCheckout])}
995 >
996 <Trash2Icon className="size-3.5" />
997 Remove checkout
998 </Button>
999 }
1000 />
1001 ) : null}
1002 <div className="flex min-h-8 flex-col items-start gap-3 px-3 pt-4 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:px-4">
1003 <div className="min-w-0">
1004 <h3 className="text-base font-semibold text-foreground">Actions</h3>
1005 <p className="text-pretty text-sm text-muted-foreground">
1006 Saved and run only in {selectedCheckoutLabel}.
1007 </p>
1008 </div>
1009 <div className="flex w-full flex-wrap gap-1.5 sm:w-auto sm:shrink-0 sm:justify-end">
1010 {importableScripts.length > 0 ? (
1011 <Menu>
1012 <MenuTrigger
1013 render={
1014 <Button size="xs" variant="ghost" disabled={isSavingScripts} type="button" />
1015 }
1016 >
1017 Import scripts
1018 <ChevronDownIcon className="size-3.5" />
1019 </MenuTrigger>
1020 <MenuPopup align="end" className="w-72">
1021 <MenuGroup>
1022 <MenuGroupLabel>Import from t3.json</MenuGroupLabel>
1023 <p className="px-2 pb-2 text-pretty text-sm text-muted-foreground">
1024 Add actions declared by this checkout without editing them first.
1025 </p>
1026 </MenuGroup>
1027 <MenuSeparator />
1028 {importableScripts.map((fileScript) => (
1029 <MenuItem
1030 key={`${fileScript.name} ${fileScript.command}`}
1031 onClick={() => void importFileScript(fileScript)}
1032 >
1033 <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4 shrink-0" />
1034 <div className="min-w-0 flex-1">
1035 <div className="truncate font-medium">{fileScript.name}</div>
1036 <div className="truncate font-mono text-muted-foreground">
1037 {fileScript.command}
1038 </div>
1039 </div>
1040 </MenuItem>
1041 ))}
1042 </MenuPopup>
1043 </Menu>
1044 ) : null}
1045 <Button
1046 size="xs"
1047 variant="outline"
1048 disabled={isSavingScripts}
1049 onClick={() =>
1050 setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })
1051 }
1052 >
1053 <PlusIcon className="size-3.5" />
1054 Add action
1055 </Button>
1056 </div>
1057 </div>
1058 {scripts.length === 0 ? (
1059 <p className="px-3 py-2 text-base text-muted-foreground sm:px-4 sm:text-sm">
1060 No actions configured for this checkout.
1061 </p>
1062 ) : (
1063 scripts.map((script) => {
9121064 const shortcutLabel = shortcutLabelForCommand(
9131065 keybindings,
9141066 commandForProjectScript(script.id),
9151067 );
9161068 return (
917 <div
1069 <SettingsRow
9181070 key={script.id}
919 className="group flex min-w-0 items-center gap-3 rounded-lg px-3 py-2 hover:bg-accent/50 sm:px-4"
920 >
921 <ScriptIcon
922 icon={script.icon}
923 className="size-4 shrink-0 text-muted-foreground"
924 />
925 <span className="shrink-0 text-sm font-medium text-foreground">
926 {script.name}
927 </span>
928 {script.runOnWorktreeCreate ? (
929 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] text-muted-foreground">
930 setup
1071 className="group py-2"
1072 title={
1073 <span className="flex min-w-0 items-center gap-2">
1074 <ScriptIcon
1075 icon={script.icon}
1076 className="size-4 shrink-0 text-muted-foreground"
1077 />
1078 <span className="max-w-40 shrink-0 truncate">{script.name}</span>
1079 <code className="min-w-0 flex-1 truncate font-mono font-normal text-muted-foreground">
1080 {script.command}
1081 </code>
1082 {script.runOnWorktreeCreate ? (
1083 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] font-normal text-muted-foreground">
1084 setup
1085 </span>
1086 ) : null}
1087 {script.previewUrl ? (
1088 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] font-normal text-muted-foreground max-sm:hidden">
1089 preview · desktop only
1090 </span>
1091 ) : null}
9311092 </span>
932 ) : null}
933 {script.previewUrl ? (
934 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] text-muted-foreground max-sm:hidden">
935 preview · desktop only
936 </span>
937 ) : null}
938 <span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground/80">
939 {script.command}
940 </span>
941 {shortcutLabel ? (
942 <span className="shrink-0 text-xs text-muted-foreground">{shortcutLabel}</span>
943 ) : null}
944 <Button
945 size="icon-xs"
946 variant="ghost"
947 className="shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
948 aria-label={`Edit ${script.name}`}
949 disabled={isSavingScripts}
950 onClick={() => setEditorRequest(editorRequestForScript(script, keybindings))}
951 >
952 <SettingsIcon className="size-3.5" />
953 </Button>
954 </div>
1093 }
1094 control={
1095 <>
1096 {shortcutLabel ? (
1097 <span className="text-xs text-muted-foreground">{shortcutLabel}</span>
1098 ) : null}
1099 <Button
1100 size="icon-xs"
1101 variant="ghost"
1102 className="shrink-0 text-muted-foreground opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"
1103 aria-label={`Edit ${script.name}`}
1104 disabled={isSavingScripts}
1105 onClick={() =>
1106 setEditorRequest(editorRequestForScript(script, keybindings))
1107 }
1108 >
1109 <SettingsIcon className="size-3.5" />
1110 </Button>
1111 </>
1112 }
1113 />
9551114 );
956 })}
957 </div>
958 )}
959 {t3File.status === "invalid" ? (
960 <SettingsRow
961 title="t3.json is invalid"
962 description="A t3.json exists at the workspace root but fails to parse, so every script and icon it declares is ignored. Check the JSON syntax and icon values."
963 className="text-warning"
964 />
965 ) : null}
966 {importableScripts.length > 0 ? (
1115 })
1116 )}
1117 {t3File.status === "invalid" ? (
1118 <SettingsRow
1119 title="t3.json is invalid"
1120 description="A t3.json exists in this checkout but fails to parse, so every action and icon it declares is ignored. Check the JSON syntax and icon values."
1121 className="text-warning"
1122 />
1123 ) : null}
1124 </SettingsSection>
1125
1126 <SettingsSection title="Danger">
9671127 <SettingsRow
968 title="Import from t3.json"
969 description={`${importableScripts.length} script${importableScripts.length === 1 ? "" : "s"} declared in this repo's t3.json ${importableScripts.length === 1 ? "is" : "are"} not set up yet.`}
1128 title={
1129 group.memberProjects.length > 1 ? "Remove this project everywhere" : "Remove project"
1130 }
1131 description={
1132 group.memberProjects.length > 1
1133 ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.`
1134 : "Deletes the project entry and its threads. Files on disk are not touched."
1135 }
9701136 control={
971 <div className="flex flex-wrap justify-end gap-1.5">
972 {importableScripts.map((fileScript) => (
973 <Button
974 key={`${fileScript.name} ${fileScript.command}`}
975 size="xs"
976 variant="outline"
977 disabled={isSavingScripts}
978 onClick={() => void importFileScript(fileScript)}
979 >
980 <ScriptIcon icon={fileScript.icon ?? "play"} className="size-3.5" />
981 {fileScript.name}
982 </Button>
983 ))}
984 </div>
1137 <Button
1138 variant="destructive-outline"
1139 onClick={() => void removeMembers(group.memberProjects)}
1140 >
1141 <Trash2Icon />
1142 {group.memberProjects.length > 1 ? "Remove all entries" : "Remove project"}
1143 </Button>
9851144 }
9861145 />
987 ) : null}
988 </SettingsSection>
989
990 <SettingsSection id="project-checkouts" title="Checkouts">
991 <div className="space-y-2 px-3 sm:px-4">
992 {group.memberProjects.map((member) => {
993 const threadCount = threadCountByMember.get(memberKey(member)) ?? 0;
994 const groupingOverride =
995 projectGroupingSettings.sidebarProjectGroupingOverrides?.[
996 deriveProjectGroupingOverrideKey(member)
997 ] ?? "inherit";
998 return (
999 <div
1000 key={member.physicalProjectKey}
1001 className="space-y-3 rounded-lg border border-border/50 p-3"
1002 >
1003 <div className="flex min-w-0 items-center gap-2">
1004 <ServerIcon className="size-3.5 shrink-0 text-muted-foreground" />
1005 <span className="min-w-0 truncate text-sm font-medium text-foreground">
1006 {member.environmentLabel ?? "Current environment"}
1007 </span>
1008 <span className="ms-auto shrink-0 text-xs text-muted-foreground">
1009 {threadCount === 1 ? "1 thread" : `${threadCount} threads`}
1010 </span>
1011 {group.memberProjects.length > 1 ? (
1012 <Button
1013 size="icon-xs"
1014 variant="ghost"
1015 className="shrink-0 text-muted-foreground hover:text-destructive-foreground"
1016 aria-label={`Remove checkout ${member.workspaceRoot}`}
1017 onClick={() => void removeMembers([member])}
1018 >
1019 <Trash2Icon className="size-3.5" />
1020 </Button>
1021 ) : null}
1022 </div>
1023 <div className="flex min-w-0 items-center gap-1.5">
1024 <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/70" />
1025 <span className="min-w-0 truncate font-mono text-xs text-muted-foreground">
1026 {member.workspaceRoot}
1027 </span>
1028 <Button
1029 size="icon-xs"
1030 variant="ghost"
1031 className="size-4 shrink-0 rounded-sm"
1032 aria-label="Copy project path"
1033 onClick={() =>
1034 copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot })
1035 }
1036 >
1037 <CopyIcon className="size-3" />
1038 </Button>
1039 </div>
1040 <div className="grid gap-3 sm:grid-cols-2">
1041 <label className="grid min-w-0 gap-1.5">
1042 <span className="text-xs font-medium text-muted-foreground">Name</span>
1043 <Input
1044 key={`${member.physicalProjectKey}:${member.title}`}
1045 aria-label={`Project name in ${member.environmentLabel ?? "current environment"}`}
1046 defaultValue={member.title}
1047 onBlur={(event) => {
1048 void renameMember(member, event.currentTarget.value);
1049 }}
1050 onKeyDown={(event) => {
1051 if (event.key === "Enter") event.currentTarget.blur();
1052 }}
1053 />
1054 </label>
1055 <label className="grid min-w-0 gap-1.5">
1056 <span className="text-xs font-medium text-muted-foreground">Grouping rule</span>
1057 <Select
1058 value={groupingOverride}
1059 onValueChange={(value) => {
1060 if (
1061 value === "inherit" ||
1062 value === "repository" ||
1063 value === "repository_path" ||
1064 value === "separate"
1065 ) {
1066 updateGroupingPreference(member, value);
1067 }
1068 }}
1069 >
1070 <SelectTrigger
1071 className="w-full"
1072 aria-label={`Grouping rule for ${member.environmentLabel ?? "current environment"}`}
1073 >
1074 <SelectValue>
1075 {groupingOverride === "inherit"
1076 ? `Default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})`
1077 : PROJECT_GROUPING_MODE_LABELS[groupingOverride]}
1078 </SelectValue>
1079 </SelectTrigger>
1080 <SelectPopup align="start" alignItemWithTrigger={false}>
1081 <SelectItem hideIndicator value="inherit">
1082 Use global default
1083 </SelectItem>
1084 <SelectItem hideIndicator value="repository">
1085 {PROJECT_GROUPING_MODE_LABELS.repository}
1086 </SelectItem>
1087 <SelectItem hideIndicator value="repository_path">
1088 {PROJECT_GROUPING_MODE_LABELS.repository_path}
1089 </SelectItem>
1090 <SelectItem hideIndicator value="separate">
1091 {PROJECT_GROUPING_MODE_LABELS.separate}
1092 </SelectItem>
1093 </SelectPopup>
1094 </Select>
1095 </label>
1096 </div>
1097 </div>
1098 );
1099 })}
1100 </div>
1101 </SettingsSection>
1102
1103 <SettingsSection title="Danger">
1104 <SettingsRow
1105 title={
1106 group.memberProjects.length > 1 ? "Remove this project everywhere" : "Remove project"
1107 }
1108 description={
1109 group.memberProjects.length > 1
1110 ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.`
1111 : "Deletes the project entry and its threads. Files on disk are not touched."
1112 }
1113 control={
1114 <Button
1115 variant="destructive-outline"
1116 onClick={() => void removeMembers(group.memberProjects)}
1117 >
1118 <Trash2Icon />
1119 {group.memberProjects.length > 1 ? "Remove all entries" : "Remove project"}
1120 </Button>
1121 }
1122 />
1123 </SettingsSection>
1146 </SettingsSection>
1147 </SettingsPageContainer>
11241148
11251149 <ProjectScriptEditorDialog
11261150 request={editorRequest}
......@@ -1138,6 +1162,6 @@ function ProjectDetail({
11381162 open={faviconPickerOpen}
11391163 projectName={group.displayName}
11401164 />
1141 </SettingsPageContainer>
1165 </>
11421166 );
11431167}
apps/web/src/components/settings/SettingsSidebarNav.tsx-4
......@@ -11,7 +11,6 @@ import {
1111 ArchiveIcon,
1212 ArrowLeftIcon,
1313 BotIcon,
14 FolderIcon,
1514 GitBranchIcon,
1615 KeyboardIcon,
1716 Link2Icon,
......@@ -49,7 +48,6 @@ const SETTINGS_SECTION_ICONS: Readonly<
4948 "/settings/general": Settings2Icon,
5049 "/settings/appearance": PaletteIcon,
5150 "/settings/keybindings": KeyboardIcon,
52 "/settings/projects": FolderIcon,
5351 "/settings/providers": BotIcon,
5452 "/settings/source-control": GitBranchIcon,
5553 "/settings/connections": Link2Icon,
......@@ -279,8 +277,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
279277 ))
280278 : SETTINGS_NAV_ITEMS.map((item) => {
281279 const Icon = item.icon;
282 // Prefix match keeps the section active on nested routes
283 // like /settings/projects/$projectKey.
284280 const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`);
285281 return (
286282 <SidebarMenuItem key={item.to}>
apps/web/src/components/settings/settingsLayout.tsx+6-4
......@@ -160,7 +160,7 @@ export function SettingsRow({
160160 ...rowProps
161161}: Omit<ComponentPropsWithoutRef<"div">, "title"> & {
162162 title: ReactNode;
163 description: ReactNode;
163 description?: ReactNode;
164164 status?: ReactNode;
165165 resetAction?: ReactNode;
166166 control?: ReactNode;
......@@ -183,9 +183,11 @@ export function SettingsRow({
183183 {resetAction}
184184 </span>
185185 </div>
186 <p className="max-w-xl text-[13px] leading-[1.45] text-muted-foreground/80">
187 {description}
188 </p>
186 {description ? (
187 <p className="max-w-xl text-[13px] leading-[1.45] text-muted-foreground/80">
188 {description}
189 </p>
190 ) : null}
189191 {status ? <div className="pt-0.5 text-xs text-muted-foreground">{status}</div> : null}
190192 </div>
191193 {control ? (
apps/web/src/components/settings/settingsSearch.test.ts+1-1
......@@ -45,7 +45,7 @@ describe("searchSettings", () => {
4545
4646 it("matches normalized title substrings", () => {
4747 expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
48 expect(searchSettings("work").map((item) => item.id)).toEqual(["project-new-thread-workspace"]);
48 expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]);
4949 expect(searchSettings("xyzzy")).toEqual([]);
5050 });
5151
apps/web/src/components/settings/settingsSearch.ts-27
......@@ -2,7 +2,6 @@ export type SettingsPath =
22 | "/settings/general"
33 | "/settings/appearance"
44 | "/settings/keybindings"
5 | "/settings/projects"
65 | "/settings/providers"
76 | "/settings/source-control"
87 | "/settings/connections"
......@@ -23,7 +22,6 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
2322 "/settings/general": "General",
2423 "/settings/appearance": "Appearance",
2524 "/settings/keybindings": "Keybindings",
26 "/settings/projects": "Projects",
2725 "/settings/providers": "Providers",
2826 "/settings/source-control": "Source Control",
2927 "/settings/connections": "Connections",
......@@ -176,31 +174,6 @@ export const SETTINGS_SEARCH_ITEMS = [
176174 title: "Keybindings",
177175 to: "/settings/keybindings",
178176 },
179 {
180 id: "projects",
181 title: "Projects",
182 to: "/settings/projects",
183 },
184 {
185 id: "project-default-model",
186 title: "Project default model",
187 to: "/settings/projects",
188 },
189 {
190 id: "project-new-thread-workspace",
191 title: "Project new-thread workspace",
192 to: "/settings/projects",
193 },
194 {
195 id: "project-scripts",
196 title: "Project scripts",
197 to: "/settings/projects",
198 },
199 {
200 id: "project-checkouts",
201 title: "Project checkouts",
202 to: "/settings/projects",
203 },
204177 {
205178 id: "providers",
206179 title: "Providers",
apps/web/src/routeTree.gen.ts+21-43
......@@ -17,15 +17,14 @@ import { Route as ChatRouteImport } from './routes/_chat'
1717import { Route as ChatIndexRouteImport } from './routes/_chat.index'
1818import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
1919import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'
20import { Route as SettingsProjectsRouteImport } from './routes/settings.projects'
2120import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings'
2221import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
2322import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
2423import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
2524import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
2625import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance'
26import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey'
2727import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback'
28import { Route as SettingsProjectsProjectKeyRouteImport } from './routes/settings.projects_.$projectKey'
2928import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
3029import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
3130
......@@ -68,11 +67,6 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({
6867 path: '/providers',
6968 getParentRoute: () => SettingsRoute,
7069} as any)
71const SettingsProjectsRoute = SettingsProjectsRouteImport.update({
72 id: '/projects',
73 path: '/projects',
74 getParentRoute: () => SettingsRoute,
75} as any)
7670const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({
7771 id: '/keybindings',
7872 path: '/keybindings',
......@@ -103,17 +97,16 @@ const SettingsAppearanceRoute = SettingsAppearanceRouteImport.update({
10397 path: '/appearance',
10498 getParentRoute: () => SettingsRoute,
10599} as any)
100const ProjectsProjectKeyRoute = ProjectsProjectKeyRouteImport.update({
101 id: '/projects/$projectKey',
102 path: '/projects/$projectKey',
103 getParentRoute: () => rootRouteImport,
104} as any)
106105const ConnectCallbackRoute = ConnectCallbackRouteImport.update({
107106 id: '/connect_/callback',
108107 path: '/connect/callback',
109108 getParentRoute: () => rootRouteImport,
110109} as any)
111const SettingsProjectsProjectKeyRoute =
112 SettingsProjectsProjectKeyRouteImport.update({
113 id: '/projects_/$projectKey',
114 path: '/projects/$projectKey',
115 getParentRoute: () => SettingsRoute,
116 } as any)
117110const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({
118111 id: '/draft/$draftId',
119112 path: '/draft/$draftId',
......@@ -133,18 +126,17 @@ export interface FileRoutesByFullPath {
133126 '/settings': typeof SettingsRouteWithChildren
134127 '/usage': typeof UsageRoute
135128 '/connect/callback': typeof ConnectCallbackRoute
129 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
136130 '/settings/appearance': typeof SettingsAppearanceRoute
137131 '/settings/archived': typeof SettingsArchivedRoute
138132 '/settings/connections': typeof SettingsConnectionsRoute
139133 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
140134 '/settings/general': typeof SettingsGeneralRoute
141135 '/settings/keybindings': typeof SettingsKeybindingsRoute
142 '/settings/projects': typeof SettingsProjectsRoute
143136 '/settings/providers': typeof SettingsProvidersRoute
144137 '/settings/source-control': typeof SettingsSourceControlRoute
145138 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
146139 '/draft/$draftId': typeof ChatDraftDraftIdRoute
147 '/settings/projects/$projectKey': typeof SettingsProjectsProjectKeyRoute
148140}
149141export interface FileRoutesByTo {
150142 '/connect': typeof ConnectRoute
......@@ -152,19 +144,18 @@ export interface FileRoutesByTo {
152144 '/settings': typeof SettingsRouteWithChildren
153145 '/usage': typeof UsageRoute
154146 '/connect/callback': typeof ConnectCallbackRoute
147 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
155148 '/settings/appearance': typeof SettingsAppearanceRoute
156149 '/settings/archived': typeof SettingsArchivedRoute
157150 '/settings/connections': typeof SettingsConnectionsRoute
158151 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
159152 '/settings/general': typeof SettingsGeneralRoute
160153 '/settings/keybindings': typeof SettingsKeybindingsRoute
161 '/settings/projects': typeof SettingsProjectsRoute
162154 '/settings/providers': typeof SettingsProvidersRoute
163155 '/settings/source-control': typeof SettingsSourceControlRoute
164156 '/': typeof ChatIndexRoute
165157 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
166158 '/draft/$draftId': typeof ChatDraftDraftIdRoute
167 '/settings/projects/$projectKey': typeof SettingsProjectsProjectKeyRoute
168159}
169160export interface FileRoutesById {
170161 __root__: typeof rootRouteImport
......@@ -174,19 +165,18 @@ export interface FileRoutesById {
174165 '/settings': typeof SettingsRouteWithChildren
175166 '/usage': typeof UsageRoute
176167 '/connect_/callback': typeof ConnectCallbackRoute
168 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
177169 '/settings/appearance': typeof SettingsAppearanceRoute
178170 '/settings/archived': typeof SettingsArchivedRoute
179171 '/settings/connections': typeof SettingsConnectionsRoute
180172 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
181173 '/settings/general': typeof SettingsGeneralRoute
182174 '/settings/keybindings': typeof SettingsKeybindingsRoute
183 '/settings/projects': typeof SettingsProjectsRoute
184175 '/settings/providers': typeof SettingsProvidersRoute
185176 '/settings/source-control': typeof SettingsSourceControlRoute
186177 '/_chat/': typeof ChatIndexRoute
187178 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
188179 '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute
189 '/settings/projects_/$projectKey': typeof SettingsProjectsProjectKeyRoute
190180}
191181export interface FileRouteTypes {
192182 fileRoutesByFullPath: FileRoutesByFullPath
......@@ -197,18 +187,17 @@ export interface FileRouteTypes {
197187 | '/settings'
198188 | '/usage'
199189 | '/connect/callback'
190 | '/projects/$projectKey'
200191 | '/settings/appearance'
201192 | '/settings/archived'
202193 | '/settings/connections'
203194 | '/settings/diagnostics'
204195 | '/settings/general'
205196 | '/settings/keybindings'
206 | '/settings/projects'
207197 | '/settings/providers'
208198 | '/settings/source-control'
209199 | '/$environmentId/$threadId'
210200 | '/draft/$draftId'
211 | '/settings/projects/$projectKey'
212201 fileRoutesByTo: FileRoutesByTo
213202 to:
214203 | '/connect'
......@@ -216,19 +205,18 @@ export interface FileRouteTypes {
216205 | '/settings'
217206 | '/usage'
218207 | '/connect/callback'
208 | '/projects/$projectKey'
219209 | '/settings/appearance'
220210 | '/settings/archived'
221211 | '/settings/connections'
222212 | '/settings/diagnostics'
223213 | '/settings/general'
224214 | '/settings/keybindings'
225 | '/settings/projects'
226215 | '/settings/providers'
227216 | '/settings/source-control'
228217 | '/'
229218 | '/$environmentId/$threadId'
230219 | '/draft/$draftId'
231 | '/settings/projects/$projectKey'
232220 id:
233221 | '__root__'
234222 | '/_chat'
......@@ -237,19 +225,18 @@ export interface FileRouteTypes {
237225 | '/settings'
238226 | '/usage'
239227 | '/connect_/callback'
228 | '/projects/$projectKey'
240229 | '/settings/appearance'
241230 | '/settings/archived'
242231 | '/settings/connections'
243232 | '/settings/diagnostics'
244233 | '/settings/general'
245234 | '/settings/keybindings'
246 | '/settings/projects'
247235 | '/settings/providers'
248236 | '/settings/source-control'
249237 | '/_chat/'
250238 | '/_chat/$environmentId/$threadId'
251239 | '/_chat/draft/$draftId'
252 | '/settings/projects_/$projectKey'
253240 fileRoutesById: FileRoutesById
254241}
255242export interface RootRouteChildren {
......@@ -259,6 +246,7 @@ export interface RootRouteChildren {
259246 SettingsRoute: typeof SettingsRouteWithChildren
260247 UsageRoute: typeof UsageRoute
261248 ConnectCallbackRoute: typeof ConnectCallbackRoute
249 ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute
262250}
263251
264252declare module '@tanstack/react-router' {
......@@ -319,13 +307,6 @@ declare module '@tanstack/react-router' {
319307 preLoaderRoute: typeof SettingsProvidersRouteImport
320308 parentRoute: typeof SettingsRoute
321309 }
322 '/settings/projects': {
323 id: '/settings/projects'
324 path: '/projects'
325 fullPath: '/settings/projects'
326 preLoaderRoute: typeof SettingsProjectsRouteImport
327 parentRoute: typeof SettingsRoute
328 }
329310 '/settings/keybindings': {
330311 id: '/settings/keybindings'
331312 path: '/keybindings'
......@@ -368,6 +349,13 @@ declare module '@tanstack/react-router' {
368349 preLoaderRoute: typeof SettingsAppearanceRouteImport
369350 parentRoute: typeof SettingsRoute
370351 }
352 '/projects/$projectKey': {
353 id: '/projects/$projectKey'
354 path: '/projects/$projectKey'
355 fullPath: '/projects/$projectKey'
356 preLoaderRoute: typeof ProjectsProjectKeyRouteImport
357 parentRoute: typeof rootRouteImport
358 }
371359 '/connect_/callback': {
372360 id: '/connect_/callback'
373361 path: '/connect/callback'
......@@ -375,13 +363,6 @@ declare module '@tanstack/react-router' {
375363 preLoaderRoute: typeof ConnectCallbackRouteImport
376364 parentRoute: typeof rootRouteImport
377365 }
378 '/settings/projects_/$projectKey': {
379 id: '/settings/projects_/$projectKey'
380 path: '/projects/$projectKey'
381 fullPath: '/settings/projects/$projectKey'
382 preLoaderRoute: typeof SettingsProjectsProjectKeyRouteImport
383 parentRoute: typeof SettingsRoute
384 }
385366 '/_chat/draft/$draftId': {
386367 id: '/_chat/draft/$draftId'
387368 path: '/draft/$draftId'
......@@ -420,10 +401,8 @@ interface SettingsRouteChildren {
420401 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute
421402 SettingsGeneralRoute: typeof SettingsGeneralRoute
422403 SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute
423 SettingsProjectsRoute: typeof SettingsProjectsRoute
424404 SettingsProvidersRoute: typeof SettingsProvidersRoute
425405 SettingsSourceControlRoute: typeof SettingsSourceControlRoute
426 SettingsProjectsProjectKeyRoute: typeof SettingsProjectsProjectKeyRoute
427406}
428407
429408const SettingsRouteChildren: SettingsRouteChildren = {
......@@ -433,10 +412,8 @@ const SettingsRouteChildren: SettingsRouteChildren = {
433412 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,
434413 SettingsGeneralRoute: SettingsGeneralRoute,
435414 SettingsKeybindingsRoute: SettingsKeybindingsRoute,
436 SettingsProjectsRoute: SettingsProjectsRoute,
437415 SettingsProvidersRoute: SettingsProvidersRoute,
438416 SettingsSourceControlRoute: SettingsSourceControlRoute,
439 SettingsProjectsProjectKeyRoute: SettingsProjectsProjectKeyRoute,
440417}
441418
442419const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
......@@ -450,6 +427,7 @@ const rootRouteChildren: RootRouteChildren = {
450427 SettingsRoute: SettingsRouteWithChildren,
451428 UsageRoute: UsageRoute,
452429 ConnectCallbackRoute: ConnectCallbackRoute,
430 ProjectsProjectKeyRoute: ProjectsProjectKeyRoute,
453431}
454432export const routeTree = rootRouteImport
455433 ._addFileChildren(rootRouteChildren)
apps/web/src/routes/projects.$projectKey.tsx created+15
......@@ -0,0 +1,15 @@
1import { createFileRoute, redirect } from "@tanstack/react-router";
2
3import { ProjectSettingsPage } from "../components/settings/ProjectSettingsPanel";
4
5export const Route = createFileRoute("/projects/$projectKey")({
6 beforeLoad: async ({ context }) => {
7 if (
8 context.authGateState.status !== "authenticated" &&
9 context.authGateState.status !== "hosted-static"
10 ) {
11 throw redirect({ to: "/pair", replace: true });
12 }
13 },
14 component: () => <ProjectSettingsPage projectKey={Route.useParams().projectKey} />,
15});
apps/web/src/routes/settings.projects.tsx deleted-11
......@@ -1,11 +0,0 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { ProjectSettingsPanel } from "../components/settings/ProjectSettingsPanel";
4
5function SettingsProjectsRoute() {
6 return <ProjectSettingsPanel selectedProjectKey={null} />;
7}
8
9export const Route = createFileRoute("/settings/projects")({
10 component: SettingsProjectsRoute,
11});
apps/web/src/routes/settings.projects_.$projectKey.tsx deleted-12
......@@ -1,12 +0,0 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { ProjectSettingsPanel } from "../components/settings/ProjectSettingsPanel";
4
5function SettingsProjectDetailRoute() {
6 const { projectKey } = Route.useParams();
7 return <ProjectSettingsPanel selectedProjectKey={projectKey} />;
8}
9
10export const Route = createFileRoute("/settings/projects_/$projectKey")({
11 component: SettingsProjectDetailRoute,
12});
packages/client-runtime/src/state/projectGrouping.test.ts+22
......@@ -71,6 +71,28 @@ describe("buildProjectGroups", () => {
7171 }
7272 });
7373
74 it("uses a shared custom title as the repository group's label", () => {
75 const projects = [
76 makeProject("first", "/work/t3code", { title: "Custom project" }),
77 makeProject("second", "/work/t3code-2", { title: "Custom project" }),
78 ];
79
80 expect(buildProjectGroups({ projects, settings: settings("repository") })[0]?.label).toBe(
81 "Custom project",
82 );
83 });
84
85 it("keeps the repository label when shared titles match its repository name", () => {
86 const projects = [
87 makeProject("first", "/work/t3code", { title: "t3code" }),
88 makeProject("second", "/work/t3code-2", { title: "t3code" }),
89 ];
90
91 expect(buildProjectGroups({ projects, settings: settings("repository") })[0]?.label).toBe(
92 "T3 Code",
93 );
94 });
95
7496 it("keeps physical clones in separate groups when requested", () => {
7597 const projects = [
7698 makeProject("t3code", "/work/t3code"),
packages/client-runtime/src/state/projectGrouping.ts+13-3
......@@ -169,16 +169,26 @@ export function deriveProjectGroupLabel(input: {
169169 readonly representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">;
170170 readonly members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>;
171171}): string {
172 const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title));
172173 const sharedDisplayNames = uniqueNonEmptyValues(
173174 input.members.map((member) => member.repositoryIdentity?.displayName),
174175 );
176 const sharedRepositoryNames = uniqueNonEmptyValues(
177 input.members.map((member) => member.repositoryIdentity?.name),
178 );
179 const sharedTitle = sharedTitles[0];
180 if (
181 sharedTitles.length === 1 &&
182 sharedTitle !== undefined &&
183 !sharedDisplayNames.includes(sharedTitle) &&
184 !sharedRepositoryNames.includes(sharedTitle)
185 ) {
186 return sharedTitle;
187 }
175188 if (sharedDisplayNames.length === 1) {
176189 return sharedDisplayNames[0]!;
177190 }
178191
179 const sharedRepositoryNames = uniqueNonEmptyValues(
180 input.members.map((member) => member.repositoryIdentity?.name),
181 );
182192 if (sharedRepositoryNames.length === 1) {
183193 return sharedRepositoryNames[0]!;
184194 }