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: {...@@ -1506,16 +1506,32 @@ function OpenCommandPaletteDialog(props: {
1506 },1506 },
1507 });1507 });
15081508
1509 actionItems.push({1509 // There is no projects listing page; the action targets the contextual
1510 kind: "action",1510 // project (active thread/draft, falling back to the first sidebar group).
1511 value: "action:project-settings",1511 const contextualProjectGroup =
1512 searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"],1512 (contextualProjectRef
1513 title: "Project settings",1513 ? projectGroupByTargetKey.get(
1514 icon: <FolderIcon className={ITEM_ICON_CLASS} />,1514 `${contextualProjectRef.environmentId}:${contextualProjectRef.projectId}`,
1515 run: async () => {1515 )
1516 await navigate({ to: "/settings/projects" });1516 : null) ??
1517 },1517 projectGroups[0] ??
1518 });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
1520 const rootGroups = buildRootGroups({ actionItems, recentThreadItems });1536 const rootGroups = buildRootGroups({ actionItems, recentThreadItems });
1521 const sourceSelectionViewValue =1537 const sourceSelectionViewValue =
apps/web/src/components/Sidebar.tsx+7-7
...@@ -42,12 +42,12 @@ import {...@@ -42,12 +42,12 @@ import {
42 FolderIcon,42 FolderIcon,
43 FolderPlusIcon,43 FolderPlusIcon,
44 GitBranchIcon,44 GitBranchIcon,
45 EllipsisIcon,
46 MessageSquareIcon,45 MessageSquareIcon,
47 PinIcon,46 PinIcon,
48 PlusIcon,47 PlusIcon,
49 SearchIcon,48 SearchIcon,
50 ServerIcon,49 ServerIcon,
50 SettingsIcon,
51 SquarePenIcon,51 SquarePenIcon,
52 TerminalIcon,52 TerminalIcon,
53 Undo2Icon,53 Undo2Icon,
...@@ -1836,7 +1836,7 @@ export default function Sidebar() {...@@ -1836,7 +1836,7 @@ export default function Sidebar() {
1836 clearSelection();1836 clearSelection();
1837 }, [clearSelection, projectScopeKey]);1837 }, [clearSelection, projectScopeKey]);
18381838
1839 const handleProjectActions = useCallback(1839 const handleProjectSettings = useCallback(
1840 (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => {1840 (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => {
1841 event.preventDefault();1841 event.preventDefault();
1842 event.stopPropagation();1842 event.stopPropagation();
...@@ -1845,7 +1845,7 @@ export default function Sidebar() {...@@ -1845,7 +1845,7 @@ export default function Sidebar() {
1845 setOpenMobile(false);1845 setOpenMobile(false);
1846 }1846 }
1847 void router.navigate({1847 void router.navigate({
1848 to: "/settings/projects/$projectKey",1848 to: "/projects/$projectKey",
1849 params: { projectKey: projectGroup.projectKey },1849 params: { projectKey: projectGroup.projectKey },
1850 });1850 });
1851 },1851 },
...@@ -3295,15 +3295,15 @@ export default function Sidebar() {...@@ -3295,15 +3295,15 @@ export default function Sidebar() {
3295 <span className="min-w-0 truncate text-sm">{project.displayName}</span>3295 <span className="min-w-0 truncate text-sm">{project.displayName}</span>
3296 <button3296 <button
3297 type="button"3297 type="button"
3298 aria-label={`Project actions for ${project.displayName}`}3298 aria-label={`Project settings for ${project.displayName}`}
3299 title={`Project actions for ${project.displayName}`}3299 title={`Project settings for ${project.displayName}`}
3300 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"3300 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"
3301 onPointerDown={(event) => event.stopPropagation()}3301 onPointerDown={(event) => event.stopPropagation()}
3302 onClick={(event) => {3302 onClick={(event) => {
3303 void handleProjectActions(event, project);3303 void handleProjectSettings(event, project);
3304 }}3304 }}
3305 >3305 >
3306 <EllipsisIcon className="size-3.5" />3306 <SettingsIcon className="size-3.5" />
3307 </button>3307 </button>
3308 </MenuRadioItem>3308 </MenuRadioItem>
3309 );3309 );
apps/web/src/components/settings/ProjectSettingsPanel.tsx+577-553
...@@ -21,9 +21,10 @@ import type {...@@ -21,9 +21,10 @@ import type {
21} from "@t3tools/contracts";21} from "@t3tools/contracts";
22import { resolveEnvModeLabel } from "../BranchToolbar.logic";22import { resolveEnvModeLabel } from "../BranchToolbar.logic";
23import { createModelSelection } from "@t3tools/shared/model";23import { 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";
25import * as Cause from "effect/Cause";26import * as Cause from "effect/Cause";
26import { CopyIcon, FolderIcon, PlusIcon, ServerIcon, SettingsIcon, Trash2Icon } from "lucide-react";27import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react";
27import { useCallback, useEffect, useMemo, useRef, useState } from "react";28import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2829
29import { useComposerDraftStore } from "../../composerDraftStore";30import { useComposerDraftStore } from "../../composerDraftStore";
...@@ -59,11 +60,7 @@ import {...@@ -59,11 +60,7 @@ import {
59import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";60import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
60import { useProjects, useThreadShells } from "../../state/entities";61import { useProjects, useThreadShells } from "../../state/entities";
61import { projectEnvironment } from "../../state/projects";62import { projectEnvironment } from "../../state/projects";
62import {63import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server";
63 primaryServerKeybindingsAtom,
64 primaryServerProvidersAtom,
65 serverEnvironment,
66} from "../../state/server";
67import { useAtomCommand } from "../../state/use-atom-command";64import { useAtomCommand } from "../../state/use-atom-command";
68import { ProviderModelPicker } from "../chat/ProviderModelPicker";65import { ProviderModelPicker } from "../chat/ProviderModelPicker";
69import { TraitsPicker } from "../chat/TraitsPicker";66import { TraitsPicker } from "../chat/TraitsPicker";
...@@ -76,9 +73,21 @@ import {...@@ -76,9 +73,21 @@ import {
76 type NewProjectScriptInput,73 type NewProjectScriptInput,
77 type ProjectScriptEditorRequest,74 type ProjectScriptEditorRequest,
78} from "../projectScriptEditor";75} from "../projectScriptEditor";
76import { cn } from "../../lib/utils";
77import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar";
79import { Button } from "../ui/button";78import { Button } from "../ui/button";
80import { Input } from "../ui/input";79import { Input } from "../ui/input";
80import {
81 Menu,
82 MenuGroup,
83 MenuGroupLabel,
84 MenuItem,
85 MenuPopup,
86 MenuSeparator,
87 MenuTrigger,
88} from "../ui/menu";
81import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";89import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
90import { SidebarInset } from "../ui/sidebar";
82import { stackedThreadToast, toastManager } from "../ui/toast";91import { stackedThreadToast, toastManager } from "../ui/toast";
83import {92import {
84 SettingResetButton,93 SettingResetButton,
...@@ -123,34 +132,115 @@ function memberKey(member: { environmentId: string; id: string }): string {...@@ -123,34 +132,115 @@ function memberKey(member: { environmentId: string; id: string }): string {
123 return `${member.environmentId}:${member.id}`;132 return `${member.environmentId}:${member.id}`;
124}133}
125134
126export function ProjectSettingsPanel({135export function ProjectSettingsPage({ projectKey }: { projectKey: string }) {
127 selectedProjectKey,
128}: {
129 selectedProjectKey: string | null;
130}) {
131 const groups = useSettingsProjectGroups();
132 const navigate = useNavigate();136 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.
137 useEffect(() => {146 useEffect(() => {
138 if (selectedProjectKey !== null) return;147 const onKeyDown = (event: KeyboardEvent) => {
139 const first = groups[0];148 if (event.defaultPrevented) return;
140 if (!first) return;149 if (event.key !== "Escape") return;
141 void navigate({150 event.preventDefault();
142 to: "/settings/projects/$projectKey",151 const activeElement = document.activeElement;
143 params: { projectKey: first.projectKey },152 if (activeElement instanceof HTMLElement) {
144 ...(currentHash ? { hash: currentHash } : {}),153 activeElement.blur();
145 replace: true,154 }
146 hashScrollIntoView: false,155 navigateBackWithinApp();
147 });156 };
148 }, [currentHash, groups, navigate, selectedProjectKey]);157 window.addEventListener("keydown", onKeyDown);
158 return () => window.removeEventListener("keydown", onKeyDown);
159 }, [navigateBackWithinApp]);
149160
150 const selected =161 return (
151 selectedProjectKey === null162 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
152 ? null163 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
153 : (groups.find((group) => group.projectKey === selectedProjectKey) ?? null);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
155 // Remember the members of the last rendered group so a grouping-rule change245 // Remember the members of the last rendered group so a grouping-rule change
156 // (which changes the group key) can follow the project to its new group.246 // (which changes the group key) can follow the project to its new group.
...@@ -163,112 +253,43 @@ export function ProjectSettingsPanel({...@@ -163,112 +253,43 @@ export function ProjectSettingsPanel({
163 };253 };
164 }, [selected]);254 }, [selected]);
165255
166 // Recover when the selected key stops matching (regroup, removal, or a256 // A grouping-rule change replaces the group key mid-visit; follow the
167 // stale deep link) instead of parking on a dead-end message.257 // project to its new key instead of parking on the not-found state.
168 useEffect(() => {258 useEffect(() => {
169 if (selectedProjectKey === null || selected !== null || groups.length === 0) return;259 if (selected !== null) return;
170 const last = lastSelectionRef.current;260 const last = lastSelectionRef.current;
171 const successor =261 if (last?.key !== projectKey) return;
172 last?.key === selectedProjectKey262 const successor = groups.find((group) =>
173 ? (groups.find((group) =>263 group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)),
174 group.memberProjects.some((member) =>264 );
175 last.memberKeys.includes(member.physicalProjectKey),
176 ),
177 ) ?? null)
178 : null;
179 if (successor) {265 if (successor) {
180 void navigate({266 void navigate({
181 to: "/settings/projects/$projectKey",267 to: "/projects/$projectKey",
182 params: { projectKey: successor.projectKey },268 params: { projectKey: successor.projectKey },
183 replace: true,269 replace: true,
184 hashScrollIntoView: false,270 hashScrollIntoView: false,
185 });271 });
186 } else {
187 void navigate({ to: "/settings/projects", replace: true, hashScrollIntoView: false });
188 }272 }
189 }, [groups, navigate, selected, selectedProjectKey]);273 }, [groups, navigate, projectKey, selected]);
190274
191 const selectProject = useCallback(275 if (!selected) {
192 (projectKey: string) => {276 return (
193 void navigate({277 <div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground">
194 to: "/settings/projects/$projectKey",278 {groups.length === 0
195 params: { projectKey },279 ? "Add a project from the sidebar to configure it here."
196 replace: true,280 : "This project is no longer available."}
197 hashScrollIntoView: false,281 </div>
198 });282 );
199 },283 }
200 [navigate],284 return <ProjectDetail key={selected.projectKey} group={selected} />;
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 );
255}285}
256286
257function ProjectDetail({287function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
258 group,
259 groups,
260 onSelectProject,
261}: {
262 group: SidebarProjectSnapshot;
263 groups: ReadonlyArray<SidebarProjectSnapshot>;
264 onSelectProject: (projectKey: string) => void;
265}) {
266 const navigate = useNavigate();288 const navigate = useNavigate();
267 const settings = usePrimarySettings();289 const settings = usePrimarySettings();
268 const updateClientSettings = useUpdateClientSettings();290 const updateClientSettings = useUpdateClientSettings();
269 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);291 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
270 const serverProviders = useAtomValue(primaryServerProvidersAtom);292 const serverProviders = useAtomValue(primaryServerProvidersAtom);
271 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
272 const threads = useThreadShells();293 const threads = useThreadShells();
273 const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });294 const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
274 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });295 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
...@@ -307,11 +328,6 @@ function ProjectDetail({...@@ -307,11 +328,6 @@ function ProjectDetail({
307 }328 }
308 return counts;329 return counts;
309 }, [threads]);330 }, [threads]);
310 const groupThreadCount = group.memberProjects.reduce(
311 (total, member) => total + (threadCountByMember.get(memberKey(member)) ?? 0),
312 0,
313 );
314
315 const reportFailure = useCallback((title: string, result: AtomCommandResult<void, unknown>) => {331 const reportFailure = useCallback((title: string, result: AtomCommandResult<void, unknown>) => {
316 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;332 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
317 const error = squashAtomCommandFailure(result);333 const error = squashAtomCommandFailure(result);
...@@ -324,15 +340,15 @@ function ProjectDetail({...@@ -324,15 +340,15 @@ function ProjectDetail({
324 );340 );
325 }, []);341 }, []);
326342
327 // Group-shared fields (default model, scripts) live on each physical343 // Group-shared fields live on each physical project record, so a
328 // project record, so a group-level edit fans out to every member.344 // group-level edit fans out to every member.
329 const updateAllMembers = useCallback(345 const updateAllMembers = useCallback(
330 async (346 async (
331 input: Partial<{347 input: Partial<{
348 title: string;
332 defaultModelSelection: ModelSelection | null;349 defaultModelSelection: ModelSelection | null;
333 defaultThreadEnvMode: ThreadEnvMode | null;350 defaultThreadEnvMode: ThreadEnvMode | null;
334 faviconPath: string | null;351 faviconPath: string | null;
335 scripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>;
336 }>,352 }>,
337 failureTitle: string,353 failureTitle: string,
338 ): Promise<AtomCommandResult<void, unknown>> => {354 ): Promise<AtomCommandResult<void, unknown>> => {
...@@ -361,6 +377,20 @@ function ProjectDetail({...@@ -361,6 +377,20 @@ function ProjectDetail({
361 [group.memberProjects, reportFailure, updateProject],377 [group.memberProjects, reportFailure, updateProject],
362 );378 );
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
364 // ----- default model -----394 // ----- default model -----
365 const storedSelection = representative.defaultModelSelection;395 const storedSelection = representative.defaultModelSelection;
366 const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection);396 const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection);
...@@ -414,14 +444,25 @@ function ProjectDetail({...@@ -414,14 +444,25 @@ function ProjectDetail({
414 [updateAllMembers],444 [updateAllMembers],
415 );445 );
416446
417 // ----- scripts -----447 // ----- checkout selection and scripts -----
418 const scripts = representative.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;
419 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);457 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
420 // Script writes replace the whole array, so two overlapping writes computed458 // Script writes replace the whole array, so two overlapping writes computed
421 // from the same snapshot would drop each other's changes. One at a time.459 // from the same snapshot would drop each other's changes. One at a time.
422 const [isSavingScripts, setIsSavingScripts] = useState(false);460 const [isSavingScripts, setIsSavingScripts] = useState(false);
423 const savingScriptsRef = useRef(false);461 const savingScriptsRef = useRef(false);
424 const t3File = useT3ProjectFileState(representative.environmentId, representative.workspaceRoot);462 const t3File = useT3ProjectFileState(
463 selectedCheckout.environmentId,
464 selectedCheckout.workspaceRoot,
465 );
425 // What the "Default" option resolves to while no override is set: the466 // What the "Default" option resolves to while no override is set: the
426 // repo's t3.json value when present, otherwise the global setting.467 // repo's t3.json value when present, otherwise the global setting.
427 const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode;468 const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode;
...@@ -456,20 +497,24 @@ function ProjectDetail({...@@ -456,20 +497,24 @@ function ProjectDetail({
456 // Captured before the write so a cleared or deleted binding can be497 // Captured before the write so a cleared or deleted binding can be
457 // removed from the keybindings config afterwards.498 // removed from the keybindings config afterwards.
458 const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand);499 const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand);
459 const updateResult = await updateAllMembers(500 const updateResult = mapAtomCommandResult(
460 { scripts: nextScripts },501 await updateProject({
461 "Failed to save scripts",502 environmentId: selectedCheckout.environmentId,
503 input: { projectId: selectedCheckout.id, scripts: nextScripts },
504 }),
505 () => undefined,
462 );506 );
463 if (updateResult._tag === "Failure") return updateResult;507 if (updateResult._tag === "Failure") {
508 reportFailure("Failed to save scripts", updateResult);
509 return updateResult;
510 }
464511
465 const keybindingRule = decodeProjectScriptKeybindingRule({512 const keybindingRule = decodeProjectScriptKeybindingRule({
466 keybinding,513 keybinding,
467 command: keybindingCommand,514 command: keybindingCommand,
468 });515 });
469 if (!isElectron) return updateResult;516 if (!isElectron) return updateResult;
470 const environmentIds = [517 const environmentIds = [selectedCheckout.environmentId];
471 ...new Set(group.memberProjects.map((member) => member.environmentId)),
472 ];
473 const previousTarget = previousKeybinding518 const previousTarget = previousKeybinding
474 ? decodeProjectScriptKeybindingRule({519 ? decodeProjectScriptKeybindingRule({
475 keybinding: previousKeybinding,520 keybinding: previousKeybinding,
...@@ -512,11 +557,12 @@ function ProjectDetail({...@@ -512,11 +557,12 @@ function ProjectDetail({
512 }557 }
513 },558 },
514 [559 [
515 group.memberProjects,
516 keybindings,560 keybindings,
517 removeKeybinding,561 removeKeybinding,
518 reportFailure,562 reportFailure,
519 updateAllMembers,563 selectedCheckout.environmentId,
564 selectedCheckout.id,
565 updateProject,
520 upsertKeybinding,566 upsertKeybinding,
521 ],567 ],
522 );568 );
...@@ -589,26 +635,6 @@ function ProjectDetail({...@@ -589,26 +635,6 @@ function ProjectDetail({
589 );635 );
590636
591 // ----- checkouts -----637 // ----- 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
612 const updateGroupingPreference = useCallback(638 const updateGroupingPreference = useCallback(
613 (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => {639 (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => {
614 const overrideKey = deriveProjectGroupingOverrideKey(member);640 const overrideKey = deriveProjectGroupingOverrideKey(member);
...@@ -689,8 +715,10 @@ function ProjectDetail({...@@ -689,8 +715,10 @@ function ProjectDetail({
689 draftStore.clearProjectDraftThreadId(projectRef);715 draftStore.clearProjectDraftThreadId(projectRef);
690 }716 }
691717
718 // The project's settings page just deleted itself; there is no projects
719 // listing to fall back to, so leave settings entirely.
692 if (isWholeGroup) {720 if (isWholeGroup) {
693 void navigate({ to: "/settings/projects", replace: true });721 void navigate({ to: "/", replace: true });
694 }722 }
695 },723 },
696 [724 [
...@@ -703,424 +731,420 @@ function ProjectDetail({...@@ -703,424 +731,420 @@ function ProjectDetail({
703 ],731 ],
704 );732 );
705733
706 const repositoryLine =734 const selectedCheckoutThreadCount = threadCountByMember.get(memberKey(selectedCheckout)) ?? 0;
707 representative.repositoryIdentity?.displayName ??735 const selectedCheckoutGrouping =
708 representative.repositoryIdentity?.canonicalKey ??736 projectGroupingSettings.sidebarProjectGroupingOverrides?.[
709 "No git remote detected";737 deriveProjectGroupingOverrideKey(selectedCheckout)
710 const environmentCount = new Set(group.memberProjects.map((member) => member.environmentId)).size;738 ] ?? "inherit";
739 const selectedCheckoutLabel = selectedCheckout.environmentLabel ?? "This machine";
711740
712 return (741 return (
713 <SettingsPageContainer className="gap-10">742 <>
714 <div className="flex items-center gap-3 px-3 sm:px-4">743 <SettingsPageContainer>
715 <ProjectFavicon744 <SettingsSection title="Project">
716 environmentId={group.environmentId}745 <SettingsRow
717 cwd={group.workspaceRoot}746 title="Name"
718 faviconPath={group.faviconPath}747 description="The shared name for this project group in the sidebar and thread lists."
719 className="size-8 shrink-0"748 control={
720 />749 <Input
721 <div className="min-w-0 flex-1">750 key={`${group.projectKey}:${group.displayName}`}
722 <h1 className="truncate text-xl font-semibold tracking-[-0.02em] text-foreground">751 className="w-full sm:w-64"
723 {group.displayName}752 aria-label="Project name"
724 </h1>753 defaultValue={group.displayName}
725 <p className="truncate text-[13px] text-muted-foreground">754 onBlur={(event) => {
726 {repositoryLine}755 void renameGroup(event.currentTarget.value);
727 {" · "}756 }}
728 {group.memberProjects.length === 1757 onKeyDown={(event) => {
729 ? "1 checkout"758 if (event.key === "Enter") event.currentTarget.blur();
730 : `${group.memberProjects.length} checkouts`}759 }}
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)}
797 />760 />
798 ) : null761 }
799 }762 />
800 control={763 <SettingsRow
801 resolvedSelection && activeEntry ? (764 title="Project icon"
802 <div className="flex flex-wrap items-center justify-end gap-1.5">765 description={faviconPath ?? "Automatic"}
803 <ProviderModelPicker766 resetAction={
804 activeInstanceId={resolvedSelection.instanceId}767 faviconPath !== null ? (
805 model={resolvedSelection.model}768 <SettingResetButton
806 lockedProvider={null}769 label="project icon"
807 instanceEntries={instanceEntries}770 disabled={isSavingFavicon}
808 modelOptionsByInstance={modelOptionsByInstance}771 onClick={() => void setFaviconPath(null)}
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 }}
814 />772 />
815 <TraitsPicker773 ) : null
816 provider={activeEntry.driverKind as ProviderDriverKind}774 }
817 models={activeEntry.models}775 control={
818 model={resolvedSelection.model}776 <div className="flex items-center gap-2">
819 prompt=""777 <ProjectFavicon
820 onPromptChange={() => {}}778 environmentId={representative.environmentId}
821 modelOptions={resolvedSelection.options ?? []}779 cwd={representative.workspaceRoot}
822 allowPromptInjectedEffort={false}780 faviconPath={faviconPath}
823 triggerVariant="outline"781 className="size-6"
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 }}
834 />782 />
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>
835 </div>793 </div>
836 ) : (794 }
837 <span className="text-sm text-muted-foreground">No providers available</span>795 />
838 )796 </SettingsSection>
839 }
840 />
841 </SettingsSection>
842797
843 <SettingsSection title="New threads">798 <SettingsSection title="New threads">
844 <SettingsRow799 <SettingsRow
845 id="project-new-thread-workspace"800 title="Model"
846 title="Workspace"801 description="New threads in this project start with this model. Applies to every checkout in this group."
847 description="Where new threads in this project start. Overrides t3.json and the global default; applies to every checkout in this group."802 resetAction={
848 resetAction={803 storedSelection !== null ? (
849 storedEnvMode !== null ? (804 <SettingResetButton
850 <SettingResetButton805 label="project default model"
851 label="project workspace default"806 onClick={() => setDefaultModel(null)}
852 onClick={() => setDefaultThreadEnvMode(null)}807 />
853 />808 ) : null
854 ) : null809 }
855 }810 control={
856 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={
857 <Select899 <Select
858 value={storedEnvMode ?? "inherit"}900 value={selectedCheckout.physicalProjectKey}
859 onValueChange={(value) => {901 onValueChange={(value) => setSelectedCheckoutKey(String(value))}
860 if (value === "worktree" || value === "local") {
861 setDefaultThreadEnvMode(value);
862 } else if (value === "inherit") {
863 setDefaultThreadEnvMode(null);
864 }
865 }}
866 >902 >
867 <SelectTrigger aria-label="New-thread workspace">903 <SelectTrigger className="max-w-64" aria-label="Selected checkout">
868 <SelectValue>904 <SelectValue>{selectedCheckoutLabel}</SelectValue>
869 {storedEnvMode === null
870 ? `Default (${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})`
871 : resolveEnvModeLabel(storedEnvMode)}
872 </SelectValue>
873 </SelectTrigger>905 </SelectTrigger>
874 <SelectPopup align="end" alignItemWithTrigger={false}>906 <SelectPopup align="end" alignItemWithTrigger={false}>
875 <SelectItem value="inherit">907 {group.memberProjects.map((member) => (
876 Default ({inheritedEnvModeSource}:{" "}908 <SelectItem
877 {resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})909 key={member.physicalProjectKey}
878 </SelectItem>910 hideIndicator
879 <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem>911 value={member.physicalProjectKey}
880 <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem>912 >
913 {member.environmentLabel ?? "This machine"} · {member.workspaceRoot}
914 </SelectItem>
915 ))}
881 </SelectPopup>916 </SelectPopup>
882 </Select>917 </Select>
883 }918 }
884 />919 >
885 </SettingsSection>920 <div className="px-3 py-2 sm:px-4">
886921 <div className="flex min-w-0 items-center rounded-lg bg-muted/30 p-1 text-base text-muted-foreground sm:text-sm">
887 <SettingsSection922 <button
888 id="project-scripts"923 aria-label="Copy checkout path"
889 title="Scripts"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"
890 headerAction={925 title="Copy path"
891 <Button926 type="button"
892 size="xs"927 onClick={() =>
893 variant="outline"928 copyPathToClipboard(selectedCheckout.workspaceRoot, {
894 disabled={isSavingScripts}929 path: selectedCheckout.workspaceRoot,
895 onClick={() =>930 })
896 setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })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>
897 }984 }
898 >985 />
899 <PlusIcon className="size-3.5" />986 {group.memberProjects.length > 1 ? (
900 Add action987 <SettingsRow
901 </Button>988 title="Remove checkout"
902 }989 description="Removes this checkout and its threads from the project group. Files on disk are not touched."
903 >990 control={
904 {scripts.length === 0 ? (991 <Button
905 <p className="px-3 py-2 text-sm text-muted-foreground sm:px-4">992 size="xs"
906 No scripts yet. Scripts run in a project terminal from the thread top bar; one script993 variant="destructive-outline"
907 can run automatically when a worktree is created.994 onClick={() => void removeMembers([selectedCheckout])}
908 </p>995 >
909 ) : (996 <Trash2Icon className="size-3.5" />
910 <div className="space-y-0.5">997 Remove checkout
911 {scripts.map((script) => {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) => {
912 const shortcutLabel = shortcutLabelForCommand(1064 const shortcutLabel = shortcutLabelForCommand(
913 keybindings,1065 keybindings,
914 commandForProjectScript(script.id),1066 commandForProjectScript(script.id),
915 );1067 );
916 return (1068 return (
917 <div1069 <SettingsRow
918 key={script.id}1070 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"1071 className="group py-2"
920 >1072 title={
921 <ScriptIcon1073 <span className="flex min-w-0 items-center gap-2">
922 icon={script.icon}1074 <ScriptIcon
923 className="size-4 shrink-0 text-muted-foreground"1075 icon={script.icon}
924 />1076 className="size-4 shrink-0 text-muted-foreground"
925 <span className="shrink-0 text-sm font-medium text-foreground">1077 />
926 {script.name}1078 <span className="max-w-40 shrink-0 truncate">{script.name}</span>
927 </span>1079 <code className="min-w-0 flex-1 truncate font-mono font-normal text-muted-foreground">
928 {script.runOnWorktreeCreate ? (1080 {script.command}
929 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] text-muted-foreground">1081 </code>
930 setup1082 {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}
931 </span>1092 </span>
932 ) : null}1093 }
933 {script.previewUrl ? (1094 control={
934 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] text-muted-foreground max-sm:hidden">1095 <>
935 preview · desktop only1096 {shortcutLabel ? (
936 </span>1097 <span className="text-xs text-muted-foreground">{shortcutLabel}</span>
937 ) : null}1098 ) : null}
938 <span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground/80">1099 <Button
939 {script.command}1100 size="icon-xs"
940 </span>1101 variant="ghost"
941 {shortcutLabel ? (1102 className="shrink-0 text-muted-foreground opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"
942 <span className="shrink-0 text-xs text-muted-foreground">{shortcutLabel}</span>1103 aria-label={`Edit ${script.name}`}
943 ) : null}1104 disabled={isSavingScripts}
944 <Button1105 onClick={() =>
945 size="icon-xs"1106 setEditorRequest(editorRequestForScript(script, keybindings))
946 variant="ghost"1107 }
947 className="shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"1108 >
948 aria-label={`Edit ${script.name}`}1109 <SettingsIcon className="size-3.5" />
949 disabled={isSavingScripts}1110 </Button>
950 onClick={() => setEditorRequest(editorRequestForScript(script, keybindings))}1111 </>
951 >1112 }
952 <SettingsIcon className="size-3.5" />1113 />
953 </Button>
954 </div>
955 );1114 );
956 })}1115 })
957 </div>1116 )}
958 )}1117 {t3File.status === "invalid" ? (
959 {t3File.status === "invalid" ? (1118 <SettingsRow
960 <SettingsRow1119 title="t3.json is invalid"
961 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."
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."1121 className="text-warning"
963 className="text-warning"1122 />
964 />1123 ) : null}
965 ) : null}1124 </SettingsSection>
966 {importableScripts.length > 0 ? (1125
1126 <SettingsSection title="Danger">
967 <SettingsRow1127 <SettingsRow
968 title="Import from t3.json"1128 title={
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.`}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 }
970 control={1136 control={
971 <div className="flex flex-wrap justify-end gap-1.5">1137 <Button
972 {importableScripts.map((fileScript) => (1138 variant="destructive-outline"
973 <Button1139 onClick={() => void removeMembers(group.memberProjects)}
974 key={`${fileScript.name} ${fileScript.command}`}1140 >
975 size="xs"1141 <Trash2Icon />
976 variant="outline"1142 {group.memberProjects.length > 1 ? "Remove all entries" : "Remove project"}
977 disabled={isSavingScripts}1143 </Button>
978 onClick={() => void importFileScript(fileScript)}
979 >
980 <ScriptIcon icon={fileScript.icon ?? "play"} className="size-3.5" />
981 {fileScript.name}
982 </Button>
983 ))}
984 </div>
985 }1144 }
986 />1145 />
987 ) : null}1146 </SettingsSection>
988 </SettingsSection>1147 </SettingsPageContainer>
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>
11241148
1125 <ProjectScriptEditorDialog1149 <ProjectScriptEditorDialog
1126 request={editorRequest}1150 request={editorRequest}
...@@ -1138,6 +1162,6 @@ function ProjectDetail({...@@ -1138,6 +1162,6 @@ function ProjectDetail({
1138 open={faviconPickerOpen}1162 open={faviconPickerOpen}
1139 projectName={group.displayName}1163 projectName={group.displayName}
1140 />1164 />
1141 </SettingsPageContainer>1165 </>
1142 );1166 );
1143}1167}
apps/web/src/components/settings/SettingsSidebarNav.tsx-4
...@@ -11,7 +11,6 @@ import {...@@ -11,7 +11,6 @@ import {
11 ArchiveIcon,11 ArchiveIcon,
12 ArrowLeftIcon,12 ArrowLeftIcon,
13 BotIcon,13 BotIcon,
14 FolderIcon,
15 GitBranchIcon,14 GitBranchIcon,
16 KeyboardIcon,15 KeyboardIcon,
17 Link2Icon,16 Link2Icon,
...@@ -49,7 +48,6 @@ const SETTINGS_SECTION_ICONS: Readonly<...@@ -49,7 +48,6 @@ const SETTINGS_SECTION_ICONS: Readonly<
49 "/settings/general": Settings2Icon,48 "/settings/general": Settings2Icon,
50 "/settings/appearance": PaletteIcon,49 "/settings/appearance": PaletteIcon,
51 "/settings/keybindings": KeyboardIcon,50 "/settings/keybindings": KeyboardIcon,
52 "/settings/projects": FolderIcon,
53 "/settings/providers": BotIcon,51 "/settings/providers": BotIcon,
54 "/settings/source-control": GitBranchIcon,52 "/settings/source-control": GitBranchIcon,
55 "/settings/connections": Link2Icon,53 "/settings/connections": Link2Icon,
...@@ -279,8 +277,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -279,8 +277,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
279 ))277 ))
280 : SETTINGS_NAV_ITEMS.map((item) => {278 : SETTINGS_NAV_ITEMS.map((item) => {
281 const Icon = item.icon;279 const Icon = item.icon;
282 // Prefix match keeps the section active on nested routes
283 // like /settings/projects/$projectKey.
284 const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`);280 const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`);
285 return (281 return (
286 <SidebarMenuItem key={item.to}>282 <SidebarMenuItem key={item.to}>
apps/web/src/components/settings/settingsLayout.tsx+6-4
...@@ -160,7 +160,7 @@ export function SettingsRow({...@@ -160,7 +160,7 @@ export function SettingsRow({
160 ...rowProps160 ...rowProps
161}: Omit<ComponentPropsWithoutRef<"div">, "title"> & {161}: Omit<ComponentPropsWithoutRef<"div">, "title"> & {
162 title: ReactNode;162 title: ReactNode;
163 description: ReactNode;163 description?: ReactNode;
164 status?: ReactNode;164 status?: ReactNode;
165 resetAction?: ReactNode;165 resetAction?: ReactNode;
166 control?: ReactNode;166 control?: ReactNode;
...@@ -183,9 +183,11 @@ export function SettingsRow({...@@ -183,9 +183,11 @@ export function SettingsRow({
183 {resetAction}183 {resetAction}
184 </span>184 </span>
185 </div>185 </div>
186 <p className="max-w-xl text-[13px] leading-[1.45] text-muted-foreground/80">186 {description ? (
187 {description}187 <p className="max-w-xl text-[13px] leading-[1.45] text-muted-foreground/80">
188 </p>188 {description}
189 </p>
190 ) : null}
189 {status ? <div className="pt-0.5 text-xs text-muted-foreground">{status}</div> : null}191 {status ? <div className="pt-0.5 text-xs text-muted-foreground">{status}</div> : null}
190 </div>192 </div>
191 {control ? (193 {control ? (
apps/web/src/components/settings/settingsSearch.test.ts+1-1
...@@ -45,7 +45,7 @@ describe("searchSettings", () => {...@@ -45,7 +45,7 @@ describe("searchSettings", () => {
4545
46 it("matches normalized title substrings", () => {46 it("matches normalized title substrings", () => {
47 expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);47 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"]);
49 expect(searchSettings("xyzzy")).toEqual([]);49 expect(searchSettings("xyzzy")).toEqual([]);
50 });50 });
5151
apps/web/src/components/settings/settingsSearch.ts-27
...@@ -2,7 +2,6 @@ export type SettingsPath =...@@ -2,7 +2,6 @@ export type SettingsPath =
2 | "/settings/general"2 | "/settings/general"
3 | "/settings/appearance"3 | "/settings/appearance"
4 | "/settings/keybindings"4 | "/settings/keybindings"
5 | "/settings/projects"
6 | "/settings/providers"5 | "/settings/providers"
7 | "/settings/source-control"6 | "/settings/source-control"
8 | "/settings/connections"7 | "/settings/connections"
...@@ -23,7 +22,6 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {...@@ -23,7 +22,6 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
23 "/settings/general": "General",22 "/settings/general": "General",
24 "/settings/appearance": "Appearance",23 "/settings/appearance": "Appearance",
25 "/settings/keybindings": "Keybindings",24 "/settings/keybindings": "Keybindings",
26 "/settings/projects": "Projects",
27 "/settings/providers": "Providers",25 "/settings/providers": "Providers",
28 "/settings/source-control": "Source Control",26 "/settings/source-control": "Source Control",
29 "/settings/connections": "Connections",27 "/settings/connections": "Connections",
...@@ -176,31 +174,6 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -176,31 +174,6 @@ export const SETTINGS_SEARCH_ITEMS = [
176 title: "Keybindings",174 title: "Keybindings",
177 to: "/settings/keybindings",175 to: "/settings/keybindings",
178 },176 },
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 },
204 {177 {
205 id: "providers",178 id: "providers",
206 title: "Providers",179 title: "Providers",
apps/web/src/routeTree.gen.ts+21-43
...@@ -17,15 +17,14 @@ import { Route as ChatRouteImport } from './routes/_chat'...@@ -17,15 +17,14 @@ import { Route as ChatRouteImport } from './routes/_chat'
17import { Route as ChatIndexRouteImport } from './routes/_chat.index'17import { Route as ChatIndexRouteImport } from './routes/_chat.index'
18import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'18import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
19import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'19import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'
20import { Route as SettingsProjectsRouteImport } from './routes/settings.projects'
21import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings'20import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings'
22import { Route as SettingsGeneralRouteImport } from './routes/settings.general'21import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
23import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'22import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
24import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'23import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
25import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'24import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
26import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance'25import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance'
26import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey'
27import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback'27import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback'
28import { Route as SettingsProjectsProjectKeyRouteImport } from './routes/settings.projects_.$projectKey'
29import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'28import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
30import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'29import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
3130
...@@ -68,11 +67,6 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({...@@ -68,11 +67,6 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({
68 path: '/providers',67 path: '/providers',
69 getParentRoute: () => SettingsRoute,68 getParentRoute: () => SettingsRoute,
70} as any)69} as any)
71const SettingsProjectsRoute = SettingsProjectsRouteImport.update({
72 id: '/projects',
73 path: '/projects',
74 getParentRoute: () => SettingsRoute,
75} as any)
76const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({70const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({
77 id: '/keybindings',71 id: '/keybindings',
78 path: '/keybindings',72 path: '/keybindings',
...@@ -103,17 +97,16 @@ const SettingsAppearanceRoute = SettingsAppearanceRouteImport.update({...@@ -103,17 +97,16 @@ const SettingsAppearanceRoute = SettingsAppearanceRouteImport.update({
103 path: '/appearance',97 path: '/appearance',
104 getParentRoute: () => SettingsRoute,98 getParentRoute: () => SettingsRoute,
105} as any)99} as any)
100const ProjectsProjectKeyRoute = ProjectsProjectKeyRouteImport.update({
101 id: '/projects/$projectKey',
102 path: '/projects/$projectKey',
103 getParentRoute: () => rootRouteImport,
104} as any)
106const ConnectCallbackRoute = ConnectCallbackRouteImport.update({105const ConnectCallbackRoute = ConnectCallbackRouteImport.update({
107 id: '/connect_/callback',106 id: '/connect_/callback',
108 path: '/connect/callback',107 path: '/connect/callback',
109 getParentRoute: () => rootRouteImport,108 getParentRoute: () => rootRouteImport,
110} as any)109} as any)
111const SettingsProjectsProjectKeyRoute =
112 SettingsProjectsProjectKeyRouteImport.update({
113 id: '/projects_/$projectKey',
114 path: '/projects/$projectKey',
115 getParentRoute: () => SettingsRoute,
116 } as any)
117const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({110const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({
118 id: '/draft/$draftId',111 id: '/draft/$draftId',
119 path: '/draft/$draftId',112 path: '/draft/$draftId',
...@@ -133,18 +126,17 @@ export interface FileRoutesByFullPath {...@@ -133,18 +126,17 @@ export interface FileRoutesByFullPath {
133 '/settings': typeof SettingsRouteWithChildren126 '/settings': typeof SettingsRouteWithChildren
134 '/usage': typeof UsageRoute127 '/usage': typeof UsageRoute
135 '/connect/callback': typeof ConnectCallbackRoute128 '/connect/callback': typeof ConnectCallbackRoute
129 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
136 '/settings/appearance': typeof SettingsAppearanceRoute130 '/settings/appearance': typeof SettingsAppearanceRoute
137 '/settings/archived': typeof SettingsArchivedRoute131 '/settings/archived': typeof SettingsArchivedRoute
138 '/settings/connections': typeof SettingsConnectionsRoute132 '/settings/connections': typeof SettingsConnectionsRoute
139 '/settings/diagnostics': typeof SettingsDiagnosticsRoute133 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
140 '/settings/general': typeof SettingsGeneralRoute134 '/settings/general': typeof SettingsGeneralRoute
141 '/settings/keybindings': typeof SettingsKeybindingsRoute135 '/settings/keybindings': typeof SettingsKeybindingsRoute
142 '/settings/projects': typeof SettingsProjectsRoute
143 '/settings/providers': typeof SettingsProvidersRoute136 '/settings/providers': typeof SettingsProvidersRoute
144 '/settings/source-control': typeof SettingsSourceControlRoute137 '/settings/source-control': typeof SettingsSourceControlRoute
145 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute138 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
146 '/draft/$draftId': typeof ChatDraftDraftIdRoute139 '/draft/$draftId': typeof ChatDraftDraftIdRoute
147 '/settings/projects/$projectKey': typeof SettingsProjectsProjectKeyRoute
148}140}
149export interface FileRoutesByTo {141export interface FileRoutesByTo {
150 '/connect': typeof ConnectRoute142 '/connect': typeof ConnectRoute
...@@ -152,19 +144,18 @@ export interface FileRoutesByTo {...@@ -152,19 +144,18 @@ export interface FileRoutesByTo {
152 '/settings': typeof SettingsRouteWithChildren144 '/settings': typeof SettingsRouteWithChildren
153 '/usage': typeof UsageRoute145 '/usage': typeof UsageRoute
154 '/connect/callback': typeof ConnectCallbackRoute146 '/connect/callback': typeof ConnectCallbackRoute
147 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
155 '/settings/appearance': typeof SettingsAppearanceRoute148 '/settings/appearance': typeof SettingsAppearanceRoute
156 '/settings/archived': typeof SettingsArchivedRoute149 '/settings/archived': typeof SettingsArchivedRoute
157 '/settings/connections': typeof SettingsConnectionsRoute150 '/settings/connections': typeof SettingsConnectionsRoute
158 '/settings/diagnostics': typeof SettingsDiagnosticsRoute151 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
159 '/settings/general': typeof SettingsGeneralRoute152 '/settings/general': typeof SettingsGeneralRoute
160 '/settings/keybindings': typeof SettingsKeybindingsRoute153 '/settings/keybindings': typeof SettingsKeybindingsRoute
161 '/settings/projects': typeof SettingsProjectsRoute
162 '/settings/providers': typeof SettingsProvidersRoute154 '/settings/providers': typeof SettingsProvidersRoute
163 '/settings/source-control': typeof SettingsSourceControlRoute155 '/settings/source-control': typeof SettingsSourceControlRoute
164 '/': typeof ChatIndexRoute156 '/': typeof ChatIndexRoute
165 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute157 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
166 '/draft/$draftId': typeof ChatDraftDraftIdRoute158 '/draft/$draftId': typeof ChatDraftDraftIdRoute
167 '/settings/projects/$projectKey': typeof SettingsProjectsProjectKeyRoute
168}159}
169export interface FileRoutesById {160export interface FileRoutesById {
170 __root__: typeof rootRouteImport161 __root__: typeof rootRouteImport
...@@ -174,19 +165,18 @@ export interface FileRoutesById {...@@ -174,19 +165,18 @@ export interface FileRoutesById {
174 '/settings': typeof SettingsRouteWithChildren165 '/settings': typeof SettingsRouteWithChildren
175 '/usage': typeof UsageRoute166 '/usage': typeof UsageRoute
176 '/connect_/callback': typeof ConnectCallbackRoute167 '/connect_/callback': typeof ConnectCallbackRoute
168 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
177 '/settings/appearance': typeof SettingsAppearanceRoute169 '/settings/appearance': typeof SettingsAppearanceRoute
178 '/settings/archived': typeof SettingsArchivedRoute170 '/settings/archived': typeof SettingsArchivedRoute
179 '/settings/connections': typeof SettingsConnectionsRoute171 '/settings/connections': typeof SettingsConnectionsRoute
180 '/settings/diagnostics': typeof SettingsDiagnosticsRoute172 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
181 '/settings/general': typeof SettingsGeneralRoute173 '/settings/general': typeof SettingsGeneralRoute
182 '/settings/keybindings': typeof SettingsKeybindingsRoute174 '/settings/keybindings': typeof SettingsKeybindingsRoute
183 '/settings/projects': typeof SettingsProjectsRoute
184 '/settings/providers': typeof SettingsProvidersRoute175 '/settings/providers': typeof SettingsProvidersRoute
185 '/settings/source-control': typeof SettingsSourceControlRoute176 '/settings/source-control': typeof SettingsSourceControlRoute
186 '/_chat/': typeof ChatIndexRoute177 '/_chat/': typeof ChatIndexRoute
187 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute178 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
188 '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute179 '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute
189 '/settings/projects_/$projectKey': typeof SettingsProjectsProjectKeyRoute
190}180}
191export interface FileRouteTypes {181export interface FileRouteTypes {
192 fileRoutesByFullPath: FileRoutesByFullPath182 fileRoutesByFullPath: FileRoutesByFullPath
...@@ -197,18 +187,17 @@ export interface FileRouteTypes {...@@ -197,18 +187,17 @@ export interface FileRouteTypes {
197 | '/settings'187 | '/settings'
198 | '/usage'188 | '/usage'
199 | '/connect/callback'189 | '/connect/callback'
190 | '/projects/$projectKey'
200 | '/settings/appearance'191 | '/settings/appearance'
201 | '/settings/archived'192 | '/settings/archived'
202 | '/settings/connections'193 | '/settings/connections'
203 | '/settings/diagnostics'194 | '/settings/diagnostics'
204 | '/settings/general'195 | '/settings/general'
205 | '/settings/keybindings'196 | '/settings/keybindings'
206 | '/settings/projects'
207 | '/settings/providers'197 | '/settings/providers'
208 | '/settings/source-control'198 | '/settings/source-control'
209 | '/$environmentId/$threadId'199 | '/$environmentId/$threadId'
210 | '/draft/$draftId'200 | '/draft/$draftId'
211 | '/settings/projects/$projectKey'
212 fileRoutesByTo: FileRoutesByTo201 fileRoutesByTo: FileRoutesByTo
213 to:202 to:
214 | '/connect'203 | '/connect'
...@@ -216,19 +205,18 @@ export interface FileRouteTypes {...@@ -216,19 +205,18 @@ export interface FileRouteTypes {
216 | '/settings'205 | '/settings'
217 | '/usage'206 | '/usage'
218 | '/connect/callback'207 | '/connect/callback'
208 | '/projects/$projectKey'
219 | '/settings/appearance'209 | '/settings/appearance'
220 | '/settings/archived'210 | '/settings/archived'
221 | '/settings/connections'211 | '/settings/connections'
222 | '/settings/diagnostics'212 | '/settings/diagnostics'
223 | '/settings/general'213 | '/settings/general'
224 | '/settings/keybindings'214 | '/settings/keybindings'
225 | '/settings/projects'
226 | '/settings/providers'215 | '/settings/providers'
227 | '/settings/source-control'216 | '/settings/source-control'
228 | '/'217 | '/'
229 | '/$environmentId/$threadId'218 | '/$environmentId/$threadId'
230 | '/draft/$draftId'219 | '/draft/$draftId'
231 | '/settings/projects/$projectKey'
232 id:220 id:
233 | '__root__'221 | '__root__'
234 | '/_chat'222 | '/_chat'
...@@ -237,19 +225,18 @@ export interface FileRouteTypes {...@@ -237,19 +225,18 @@ export interface FileRouteTypes {
237 | '/settings'225 | '/settings'
238 | '/usage'226 | '/usage'
239 | '/connect_/callback'227 | '/connect_/callback'
228 | '/projects/$projectKey'
240 | '/settings/appearance'229 | '/settings/appearance'
241 | '/settings/archived'230 | '/settings/archived'
242 | '/settings/connections'231 | '/settings/connections'
243 | '/settings/diagnostics'232 | '/settings/diagnostics'
244 | '/settings/general'233 | '/settings/general'
245 | '/settings/keybindings'234 | '/settings/keybindings'
246 | '/settings/projects'
247 | '/settings/providers'235 | '/settings/providers'
248 | '/settings/source-control'236 | '/settings/source-control'
249 | '/_chat/'237 | '/_chat/'
250 | '/_chat/$environmentId/$threadId'238 | '/_chat/$environmentId/$threadId'
251 | '/_chat/draft/$draftId'239 | '/_chat/draft/$draftId'
252 | '/settings/projects_/$projectKey'
253 fileRoutesById: FileRoutesById240 fileRoutesById: FileRoutesById
254}241}
255export interface RootRouteChildren {242export interface RootRouteChildren {
...@@ -259,6 +246,7 @@ export interface RootRouteChildren {...@@ -259,6 +246,7 @@ export interface RootRouteChildren {
259 SettingsRoute: typeof SettingsRouteWithChildren246 SettingsRoute: typeof SettingsRouteWithChildren
260 UsageRoute: typeof UsageRoute247 UsageRoute: typeof UsageRoute
261 ConnectCallbackRoute: typeof ConnectCallbackRoute248 ConnectCallbackRoute: typeof ConnectCallbackRoute
249 ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute
262}250}
263251
264declare module '@tanstack/react-router' {252declare module '@tanstack/react-router' {
...@@ -319,13 +307,6 @@ declare module '@tanstack/react-router' {...@@ -319,13 +307,6 @@ declare module '@tanstack/react-router' {
319 preLoaderRoute: typeof SettingsProvidersRouteImport307 preLoaderRoute: typeof SettingsProvidersRouteImport
320 parentRoute: typeof SettingsRoute308 parentRoute: typeof SettingsRoute
321 }309 }
322 '/settings/projects': {
323 id: '/settings/projects'
324 path: '/projects'
325 fullPath: '/settings/projects'
326 preLoaderRoute: typeof SettingsProjectsRouteImport
327 parentRoute: typeof SettingsRoute
328 }
329 '/settings/keybindings': {310 '/settings/keybindings': {
330 id: '/settings/keybindings'311 id: '/settings/keybindings'
331 path: '/keybindings'312 path: '/keybindings'
...@@ -368,6 +349,13 @@ declare module '@tanstack/react-router' {...@@ -368,6 +349,13 @@ declare module '@tanstack/react-router' {
368 preLoaderRoute: typeof SettingsAppearanceRouteImport349 preLoaderRoute: typeof SettingsAppearanceRouteImport
369 parentRoute: typeof SettingsRoute350 parentRoute: typeof SettingsRoute
370 }351 }
352 '/projects/$projectKey': {
353 id: '/projects/$projectKey'
354 path: '/projects/$projectKey'
355 fullPath: '/projects/$projectKey'
356 preLoaderRoute: typeof ProjectsProjectKeyRouteImport
357 parentRoute: typeof rootRouteImport
358 }
371 '/connect_/callback': {359 '/connect_/callback': {
372 id: '/connect_/callback'360 id: '/connect_/callback'
373 path: '/connect/callback'361 path: '/connect/callback'
...@@ -375,13 +363,6 @@ declare module '@tanstack/react-router' {...@@ -375,13 +363,6 @@ declare module '@tanstack/react-router' {
375 preLoaderRoute: typeof ConnectCallbackRouteImport363 preLoaderRoute: typeof ConnectCallbackRouteImport
376 parentRoute: typeof rootRouteImport364 parentRoute: typeof rootRouteImport
377 }365 }
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 }
385 '/_chat/draft/$draftId': {366 '/_chat/draft/$draftId': {
386 id: '/_chat/draft/$draftId'367 id: '/_chat/draft/$draftId'
387 path: '/draft/$draftId'368 path: '/draft/$draftId'
...@@ -420,10 +401,8 @@ interface SettingsRouteChildren {...@@ -420,10 +401,8 @@ interface SettingsRouteChildren {
420 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute401 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute
421 SettingsGeneralRoute: typeof SettingsGeneralRoute402 SettingsGeneralRoute: typeof SettingsGeneralRoute
422 SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute403 SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute
423 SettingsProjectsRoute: typeof SettingsProjectsRoute
424 SettingsProvidersRoute: typeof SettingsProvidersRoute404 SettingsProvidersRoute: typeof SettingsProvidersRoute
425 SettingsSourceControlRoute: typeof SettingsSourceControlRoute405 SettingsSourceControlRoute: typeof SettingsSourceControlRoute
426 SettingsProjectsProjectKeyRoute: typeof SettingsProjectsProjectKeyRoute
427}406}
428407
429const SettingsRouteChildren: SettingsRouteChildren = {408const SettingsRouteChildren: SettingsRouteChildren = {
...@@ -433,10 +412,8 @@ const SettingsRouteChildren: SettingsRouteChildren = {...@@ -433,10 +412,8 @@ const SettingsRouteChildren: SettingsRouteChildren = {
433 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,412 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,
434 SettingsGeneralRoute: SettingsGeneralRoute,413 SettingsGeneralRoute: SettingsGeneralRoute,
435 SettingsKeybindingsRoute: SettingsKeybindingsRoute,414 SettingsKeybindingsRoute: SettingsKeybindingsRoute,
436 SettingsProjectsRoute: SettingsProjectsRoute,
437 SettingsProvidersRoute: SettingsProvidersRoute,415 SettingsProvidersRoute: SettingsProvidersRoute,
438 SettingsSourceControlRoute: SettingsSourceControlRoute,416 SettingsSourceControlRoute: SettingsSourceControlRoute,
439 SettingsProjectsProjectKeyRoute: SettingsProjectsProjectKeyRoute,
440}417}
441418
442const SettingsRouteWithChildren = SettingsRoute._addFileChildren(419const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
...@@ -450,6 +427,7 @@ const rootRouteChildren: RootRouteChildren = {...@@ -450,6 +427,7 @@ const rootRouteChildren: RootRouteChildren = {
450 SettingsRoute: SettingsRouteWithChildren,427 SettingsRoute: SettingsRouteWithChildren,
451 UsageRoute: UsageRoute,428 UsageRoute: UsageRoute,
452 ConnectCallbackRoute: ConnectCallbackRoute,429 ConnectCallbackRoute: ConnectCallbackRoute,
430 ProjectsProjectKeyRoute: ProjectsProjectKeyRoute,
453}431}
454export const routeTree = rootRouteImport432export const routeTree = rootRouteImport
455 ._addFileChildren(rootRouteChildren)433 ._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", () => {...@@ -71,6 +71,28 @@ describe("buildProjectGroups", () => {
71 }71 }
72 });72 });
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
74 it("keeps physical clones in separate groups when requested", () => {96 it("keeps physical clones in separate groups when requested", () => {
75 const projects = [97 const projects = [
76 makeProject("t3code", "/work/t3code"),98 makeProject("t3code", "/work/t3code"),
packages/client-runtime/src/state/projectGrouping.ts+13-3
...@@ -169,16 +169,26 @@ export function deriveProjectGroupLabel(input: {...@@ -169,16 +169,26 @@ export function deriveProjectGroupLabel(input: {
169 readonly representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">;169 readonly representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">;
170 readonly members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>;170 readonly members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>;
171}): string {171}): string {
172 const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title));
172 const sharedDisplayNames = uniqueNonEmptyValues(173 const sharedDisplayNames = uniqueNonEmptyValues(
173 input.members.map((member) => member.repositoryIdentity?.displayName),174 input.members.map((member) => member.repositoryIdentity?.displayName),
174 );175 );
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 }
175 if (sharedDisplayNames.length === 1) {188 if (sharedDisplayNames.length === 1) {
176 return sharedDisplayNames[0]!;189 return sharedDisplayNames[0]!;
177 }190 }
178191
179 const sharedRepositoryNames = uniqueNonEmptyValues(
180 input.members.map((member) => member.repositoryIdentity?.name),
181 );
182 if (sharedRepositoryNames.length === 1) {192 if (sharedRepositoryNames.length === 1) {
183 return sharedRepositoryNames[0]!;193 return sharedRepositoryNames[0]!;
184 }194 }