authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-08 21:50:03-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-08 18:50:03-07:00
log288d8e3457f0466da2cbef2eab648331c969b8a7
tree8291864bac7c791f5728a4fa3e46a5260d208884
parent5208bdeb0db95063091a29f97af3548436c5f291
signature Signed by PGP key B5690EEEBB952194

feat(web): overhaul project settings into a real settings page (#5768)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

11 files changed, 1609 insertions(+), 756 deletions(-)

apps/web/src/components/CommandPalette.tsx+11
......@@ -1505,6 +1505,17 @@ function OpenCommandPaletteDialog(props: {
15051505 },
15061506 });
15071507
1508 actionItems.push({
1509 kind: "action",
1510 value: "action:project-settings",
1511 searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"],
1512 title: "Project settings",
1513 icon: <FolderIcon className={ITEM_ICON_CLASS} />,
1514 run: async () => {
1515 await navigate({ to: "/settings/projects" });
1516 },
1517 });
1518
15081519 const rootGroups = buildRootGroups({ actionItems, recentThreadItems });
15091520 const sourceSelectionViewValue =
15101521 addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`;
apps/web/src/components/ProjectScriptsControl.tsx+32-382
......@@ -1,61 +1,28 @@
11import type {
22 ProjectScript,
3 ProjectScriptIcon,
43 ResolvedKeybindingsConfig,
54 T3ProjectFileScript,
65} from "@t3tools/contracts";
76import {
87 isAtomCommandInterrupted,
98 squashAtomCommandFailure,
10 type AtomCommandResult,
119} from "@t3tools/client-runtime/state/runtime";
12import {
13 BugIcon,
14 ChevronDownIcon,
15 DownloadIcon,
16 FlaskConicalIcon,
17 HammerIcon,
18 ListChecksIcon,
19 PlayIcon,
20 PlusIcon,
21 SettingsIcon,
22 WrenchIcon,
23} from "lucide-react";
24import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react";
10import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon } from "lucide-react";
11import { useCallback, useMemo, useState } from "react";
2512
26import {
27 keybindingValueForCommand,
28 decodeProjectScriptKeybindingRule,
29} from "~/lib/projectScriptKeybindings";
30import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic";
31import {
32 commandForProjectScript,
33 nextProjectScriptId,
34 primaryProjectScript,
35} from "~/projectScripts";
13import { commandForProjectScript, primaryProjectScript } from "~/projectScripts";
3614import { shortcutLabelForCommand } from "~/keybindings";
3715import {
38 AlertDialog,
39 AlertDialogClose,
40 AlertDialogDescription,
41 AlertDialogFooter,
42 AlertDialogHeader,
43 AlertDialogPopup,
44 AlertDialogTitle,
45} from "./ui/alert-dialog";
16 EMPTY_PROJECT_SCRIPT_INPUT,
17 editorRequestForScript,
18 ProjectScriptEditorDialog,
19 ScriptIcon,
20 type NewProjectScriptInput,
21 type ProjectScriptActionResult,
22 type ProjectScriptEditorRequest,
23} from "./projectScriptEditor";
4624import { Button } from "./ui/button";
47import {
48 Dialog,
49 DialogDescription,
50 DialogFooter,
51 DialogHeader,
52 DialogPanel,
53 DialogPopup,
54 DialogTitle,
55} from "./ui/dialog";
5625import { Group, GroupSeparator } from "./ui/group";
57import { Input } from "./ui/input";
58import { Label } from "./ui/label";
5926import {
6027 Menu,
6128 MenuGroup,
......@@ -66,48 +33,9 @@ import {
6633 MenuShortcut,
6734 MenuTrigger,
6835} from "./ui/menu";
69import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
70import { Switch } from "./ui/switch";
71import { Textarea } from "./ui/textarea";
7236import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
7337
74const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [
75 { id: "play", label: "Play" },
76 { id: "test", label: "Test" },
77 { id: "lint", label: "Lint" },
78 { id: "configure", label: "Configure" },
79 { id: "build", label: "Build" },
80 { id: "debug", label: "Debug" },
81];
82
83function ScriptIcon({
84 icon,
85 className = "size-3.5",
86}: {
87 icon: ProjectScriptIcon;
88 className?: string;
89}) {
90 if (icon === "test") return <FlaskConicalIcon className={className} />;
91 if (icon === "lint") return <ListChecksIcon className={className} />;
92 if (icon === "configure") return <WrenchIcon className={className} />;
93 if (icon === "build") return <HammerIcon className={className} />;
94 if (icon === "debug") return <BugIcon className={className} />;
95 return <PlayIcon className={className} />;
96}
97
98export interface NewProjectScriptInput {
99 name: string;
100 command: string;
101 icon: ProjectScriptIcon;
102 runOnWorktreeCreate: boolean;
103 keybinding: string | null;
104 /** Optional URL to open in the in-app preview when this script runs. */
105 previewUrl: string | null;
106 /** When true, automatically open the preview panel pointed at `previewUrl`. */
107 autoOpenPreview: boolean;
108}
109
110export type ProjectScriptActionResult = AtomCommandResult<void, unknown>;
38export type { NewProjectScriptInput, ProjectScriptActionResult };
11139
11240const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];
11341
......@@ -136,23 +64,11 @@ export default function ProjectScriptsControl({
13664 onUpdateScript,
13765 onDeleteScript,
13866}: ProjectScriptsControlProps) {
139 const addScriptFormId = React.useId();
140 const [editingScriptId, setEditingScriptId] = useState<string | null>(null);
14167 const [actionsMenuOpen, setActionsMenuOpen] = useState({
14268 scripts: false,
14369 imports: false,
14470 });
145 const [dialogOpen, setDialogOpen] = useState(false);
146 const [name, setName] = useState("");
147 const [command, setCommand] = useState("");
148 const [icon, setIcon] = useState<ProjectScriptIcon>("play");
149 const [iconPickerOpen, setIconPickerOpen] = useState(false);
150 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
151 const [keybinding, setKeybinding] = useState("");
152 const [previewUrl, setPreviewUrl] = useState("");
153 const [autoOpenPreview, setAutoOpenPreview] = useState(false);
154 const [validationError, setValidationError] = useState<string | null>(null);
155 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
71 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
15672
15773 const primaryScript = useMemo(() => {
15874 if (preferredScriptId) {
......@@ -173,112 +89,23 @@ export default function ProjectScriptsControl({
17389 ),
17490 [fileScripts, scripts],
17591 );
176 const isEditing = editingScriptId !== null;
17792 const dropdownItemClassName =
17893 "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground";
17994
180 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {
181 if (event.key === "Tab") return;
182 event.preventDefault();
183 if (event.key === "Backspace" || event.key === "Delete") {
184 setKeybinding("");
185 return;
186 }
187 const next = keybindingFromKeyboardEvent(event, navigator.platform);
188 if (!next) return;
189 setKeybinding(next);
190 };
191
192 const submitAddScript = async (event: FormEvent) => {
193 event.preventDefault();
194 const trimmedName = name.trim();
195 const trimmedCommand = command.trim();
196 if (trimmedName.length === 0) {
197 setValidationError("Name is required.");
198 return;
199 }
200 if (trimmedCommand.length === 0) {
201 setValidationError("Command is required.");
202 return;
203 }
204
205 setValidationError(null);
206 let payload: NewProjectScriptInput;
207 try {
208 const scriptIdForValidation =
209 editingScriptId ??
210 nextProjectScriptId(
211 trimmedName,
212 scripts.map((script) => script.id),
213 );
214 const keybindingRule = decodeProjectScriptKeybindingRule({
215 keybinding,
216 command: commandForProjectScript(scriptIdForValidation),
217 });
218 const trimmedPreviewUrl = previewUrl.trim();
219 payload = {
220 name: trimmedName,
221 command: trimmedCommand,
222 icon,
223 runOnWorktreeCreate,
224 keybinding: keybindingRule?.key ?? null,
225 previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null,
226 autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false,
227 } satisfies NewProjectScriptInput;
228 } catch (error) {
229 setValidationError(error instanceof Error ? error.message : "Failed to save action.");
230 return;
231 }
232
233 const result = editingScriptId
234 ? await onUpdateScript(editingScriptId, payload)
235 : await onAddScript(payload);
236 if (result._tag === "Failure") {
237 if (!isAtomCommandInterrupted(result)) {
238 const error = squashAtomCommandFailure(result);
239 setValidationError(error instanceof Error ? error.message : "Failed to save action.");
240 }
241 return;
242 }
243 setDialogOpen(false);
244 setIconPickerOpen(false);
245 };
246
24795 const openAddDialog = () => {
248 setEditingScriptId(null);
249 setName("");
250 setCommand("");
251 setIcon("play");
252 setIconPickerOpen(false);
253 setRunOnWorktreeCreate(false);
254 setKeybinding("");
255 setPreviewUrl("");
256 setAutoOpenPreview(false);
257 setValidationError(null);
258 setDialogOpen(true);
96 setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT });
25997 };
26098
26199 const openEditDialog = (script: ProjectScript) => {
262100 setActionsMenuOpen({ scripts: false, imports: false });
263 setEditingScriptId(script.id);
264 setName(script.name);
265 setCommand(script.command);
266 setIcon(script.icon);
267 setIconPickerOpen(false);
268 setRunOnWorktreeCreate(script.runOnWorktreeCreate);
269 setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? "");
270 setPreviewUrl(script.previewUrl ?? "");
271 setAutoOpenPreview(script.autoOpenPreview ?? false);
272 setValidationError(null);
273 setDialogOpen(true);
101 setEditorRequest(editorRequestForScript(script, keybindings));
274102 };
275103
276 const confirmDeleteScript = useCallback(() => {
277 if (!editingScriptId) return;
278 setDeleteConfirmOpen(false);
279 setDialogOpen(false);
280 void onDeleteScript(editingScriptId);
281 }, [editingScriptId, onDeleteScript]);
104 const submitScript = useCallback(
105 (scriptId: string | null, input: NewProjectScriptInput) =>
106 scriptId === null ? onAddScript(input) : onUpdateScript(scriptId, input),
107 [onAddScript, onUpdateScript],
108 );
282109
283110 const importFileScript = async (fileScript: T3ProjectFileScript) => {
284111 const payload: NewProjectScriptInput = {
......@@ -295,17 +122,11 @@ export default function ProjectScriptsControl({
295122 // Surface the failure through the regular add dialog, prefilled so the
296123 // user can adjust and retry.
297124 const error = squashAtomCommandFailure(result);
298 setEditingScriptId(null);
299 setName(payload.name);
300 setCommand(payload.command);
301 setIcon(payload.icon);
302 setIconPickerOpen(false);
303 setRunOnWorktreeCreate(payload.runOnWorktreeCreate);
304 setKeybinding("");
305 setPreviewUrl(payload.previewUrl ?? "");
306 setAutoOpenPreview(payload.autoOpenPreview);
307 setValidationError(error instanceof Error ? error.message : "Failed to import action.");
308 setDialogOpen(true);
125 setEditorRequest({
126 scriptId: null,
127 initial: payload,
128 error: error instanceof Error ? error.message : "Failed to import action.",
129 });
309130 }
310131 };
311132
......@@ -466,184 +287,13 @@ export default function ProjectScriptsControl({
466287 </Tooltip>
467288 )}
468289
469 <Dialog
470 onOpenChange={(open) => {
471 setDialogOpen(open);
472 if (!open) {
473 setIconPickerOpen(false);
474 }
475 }}
476 onOpenChangeComplete={(open) => {
477 if (open) return;
478 setEditingScriptId(null);
479 setName("");
480 setCommand("");
481 setIcon("play");
482 setRunOnWorktreeCreate(false);
483 setKeybinding("");
484 setPreviewUrl("");
485 setAutoOpenPreview(false);
486 setValidationError(null);
487 }}
488 open={dialogOpen}
489 >
490 <DialogPopup>
491 <DialogHeader>
492 <DialogTitle>{isEditing ? "Edit Action" : "Add Action"}</DialogTitle>
493 <DialogDescription>
494 Actions are project-scoped commands you can run from the top bar or keybindings.
495 </DialogDescription>
496 </DialogHeader>
497 <DialogPanel>
498 <form id={addScriptFormId} className="space-y-4" onSubmit={submitAddScript}>
499 <div className="space-y-1.5">
500 <Label htmlFor="script-name">Name</Label>
501 <div className="flex items-center gap-2">
502 <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}>
503 <PopoverTrigger
504 render={
505 <Button
506 type="button"
507 variant="outline"
508 className="size-9 shrink-0 hover:bg-popover active:bg-popover data-pressed:bg-popover data-pressed:shadow-xs/5 data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:border-transparent dark:bg-white/[0.035] dark:data-pressed:before:shadow-none"
509 aria-label="Choose icon"
510 />
511 }
512 >
513 <ScriptIcon icon={icon} className="size-4.5" />
514 </PopoverTrigger>
515 <PopoverPopup align="start">
516 <div className="grid grid-cols-3 gap-2">
517 {SCRIPT_ICONS.map((entry) => {
518 const isSelected = entry.id === icon;
519 return (
520 <button
521 key={entry.id}
522 type="button"
523 className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${
524 isSelected
525 ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30"
526 : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]"
527 }`}
528 onClick={() => {
529 setIcon(entry.id);
530 setIconPickerOpen(false);
531 }}
532 >
533 <ScriptIcon icon={entry.id} className="size-4" />
534 <span>{entry.label}</span>
535 </button>
536 );
537 })}
538 </div>
539 </PopoverPopup>
540 </Popover>
541 <Input
542 id="script-name"
543 autoFocus
544 placeholder="Test"
545 value={name}
546 onChange={(event) => setName(event.target.value)}
547 />
548 </div>
549 </div>
550 <div className="space-y-1.5">
551 <Label htmlFor="script-keybinding">Keybinding</Label>
552 <Input
553 id="script-keybinding"
554 placeholder="Press shortcut"
555 value={keybinding}
556 readOnly
557 onKeyDown={captureKeybinding}
558 />
559 <p className="text-xs text-muted-foreground">
560 Press a shortcut. Use <code>Backspace</code> to clear.
561 </p>
562 </div>
563 <div className="space-y-1.5">
564 <Label htmlFor="script-command">Command</Label>
565 <Textarea
566 id="script-command"
567 placeholder="bun test"
568 value={command}
569 onChange={(event) => setCommand(event.target.value)}
570 />
571 </div>
572 <div className="space-y-1.5">
573 <Label htmlFor="script-preview-url">Preview URL (optional)</Label>
574 <Input
575 id="script-preview-url"
576 placeholder="http://localhost:5173"
577 value={previewUrl}
578 onChange={(event) => setPreviewUrl(event.target.value)}
579 />
580 <p className="text-xs text-muted-foreground">
581 Open this URL in the in-app preview when this action runs.
582 </p>
583 </div>
584 <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035]">
585 <span>Run automatically on worktree creation</span>
586 <Switch
587 checked={runOnWorktreeCreate}
588 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
589 />
590 </label>
591 <label
592 className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${
593 previewUrl.trim().length === 0 ? "opacity-60" : ""
594 }`}
595 >
596 <span>Open preview automatically when this action runs</span>
597 <Switch
598 checked={autoOpenPreview}
599 disabled={previewUrl.trim().length === 0}
600 onCheckedChange={(checked) => setAutoOpenPreview(Boolean(checked))}
601 />
602 </label>
603 {validationError && <p className="text-sm text-destructive">{validationError}</p>}
604 </form>
605 </DialogPanel>
606 <DialogFooter className="dark:border-transparent dark:bg-transparent">
607 {isEditing && (
608 <Button
609 type="button"
610 variant="destructive-outline"
611 className="mr-auto"
612 onClick={() => setDeleteConfirmOpen(true)}
613 >
614 Delete
615 </Button>
616 )}
617 <Button
618 type="button"
619 variant="outline"
620 onClick={() => {
621 setDialogOpen(false);
622 }}
623 >
624 Cancel
625 </Button>
626 <Button form={addScriptFormId} type="submit">
627 {isEditing ? "Save changes" : "Save action"}
628 </Button>
629 </DialogFooter>
630 </DialogPopup>
631 </Dialog>
632
633 <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
634 <AlertDialogPopup>
635 <AlertDialogHeader>
636 <AlertDialogTitle>Delete action "{name}"?</AlertDialogTitle>
637 <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
638 </AlertDialogHeader>
639 <AlertDialogFooter>
640 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
641 <Button variant="destructive" onClick={confirmDeleteScript}>
642 Delete action
643 </Button>
644 </AlertDialogFooter>
645 </AlertDialogPopup>
646 </AlertDialog>
290 <ProjectScriptEditorDialog
291 request={editorRequest}
292 scripts={scripts}
293 onSubmit={submitScript}
294 onDelete={(scriptId) => void onDeleteScript(scriptId)}
295 onClose={() => setEditorRequest(null)}
296 />
647297 </>
648298 );
649299}
apps/web/src/components/Sidebar.tsx+11-365
......@@ -28,7 +28,7 @@ import {
2828 scopeThreadRef,
2929 scopedThreadKey,
3030} from "@t3tools/client-runtime/environment";
31import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
31import type { ScopedThreadRef } from "@t3tools/contracts";
3232import type { TimestampFormat } from "@t3tools/contracts/settings";
3333import {
3434 AlarmClockIcon,
......@@ -39,7 +39,6 @@ import {
3939 CircleCheckIcon,
4040 CircleDashedIcon,
4141 ClockIcon,
42 CopyIcon,
4342 FolderIcon,
4443 FolderPlusIcon,
4544 GitBranchIcon,
......@@ -51,7 +50,6 @@ import {
5150 ServerIcon,
5251 SquarePenIcon,
5352 TerminalIcon,
54 Trash2Icon,
5553 Undo2Icon,
5654 XIcon,
5755} from "lucide-react";
......@@ -89,14 +87,9 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina
8987import { isMacPlatform } from "~/lib/utils";
9088import { useOpenPrLink } from "../lib/openPullRequestLink";
9189import { readLocalApi } from "../localApi";
92import {
93 deriveProjectGroupingOverrideKey,
94 getProjectOrderKey,
95 selectProjectGroupingSettings,
96} from "../logicalProject";
90import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
9791import {
9892 buildSidebarProjectSnapshots,
99 type SidebarProjectGroupMember,
10093 type SidebarProjectSnapshot,
10194} from "../sidebarProjectGrouping";
10295import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
......@@ -105,7 +98,7 @@ import { useThreadActions } from "../hooks/useThreadActions";
10598import { useHandleNewThread } from "../hooks/useHandleNewThread";
10699import { openCommandPalette } from "../commandPaletteBus";
107100import { startNewThreadFromContext } from "../lib/chatThreadActions";
108import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings";
101import { useClientSettings } from "../hooks/useSettings";
109102import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
110103import { useNowMinute } from "../hooks/useNowMinute";
111104import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
......@@ -113,7 +106,6 @@ import { useProjects, useThreadShells } from "../state/entities";
113106import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server";
114107import { vcsEnvironment } from "../state/vcs";
115108import { threadEnvironment } from "../state/threads";
116import { projectEnvironment } from "../state/projects";
117109import { useEnvironmentQuery } from "../state/query";
118110import { useAtomCommand } from "../state/use-atom-command";
119111import {
......@@ -138,7 +130,6 @@ import {
138130 resolveSidebarThreadStatus,
139131 searchSidebarThreadsByTitle,
140132 resolveWorkingStartedAt,
141 shouldNavigateAfterProjectRemoval,
142133 sortLogicalProjectsForSidebar,
143134 sortPinnedThreadsForSidebar,
144135 sortSettledThreadsForSidebar,
......@@ -166,18 +157,8 @@ import { primaryServerProvidersAtom } from "../state/server";
166157import { useThreadRunningTerminalIds } from "../state/terminalSessions";
167158import { stackedThreadToast, toastManager } from "./ui/toast";
168159import { Button } from "./ui/button";
169import {
170 Dialog,
171 DialogDescription,
172 DialogFooter,
173 DialogHeader,
174 DialogPanel,
175 DialogPopup,
176 DialogTitle,
177} from "./ui/dialog";
178160import { Input } from "./ui/input";
179161import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
180import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
181162import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";
182163import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
183164import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
......@@ -188,11 +169,6 @@ import { useComposerDraftStore } from "../composerDraftStore";
188169// stays behind an explicit Show more.
189170const SETTLED_TAIL_INITIAL_COUNT = 10;
190171const SETTLED_TAIL_PAGE_COUNT = 25;
191const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
192 repository: "Group by repository",
193 repository_path: "Group by repository path",
194 separate: "Keep separate",
195};
196172
197173function compactSidebarTimeLabel(label: string): string {
198174 if (label === "just now") return "now";
......@@ -1379,13 +1355,6 @@ export default function Sidebar() {
13791355 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
13801356 reportFailure: false,
13811357 });
1382 const deleteProject = useAtomCommand(projectEnvironment.delete, {
1383 reportFailure: false,
1384 });
1385 const updateProject = useAtomCommand(projectEnvironment.update, {
1386 reportFailure: false,
1387 });
1388 const updateSettings = useUpdateClientSettings();
13891358 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
13901359 onCopy: ({ path }) => {
13911360 toastManager.add({
......@@ -1423,9 +1392,6 @@ export default function Sidebar() {
14231392 );
14241393 },
14251394 });
1426 const [projectActionsTarget, setProjectActionsTarget] = useState<SidebarProjectSnapshot | null>(
1427 null,
1428 );
14291395 const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false);
14301396 const newThreadContext = useHandleNewThread();
14311397 const openAddProjectCommandPalette = useCallback(
......@@ -1602,156 +1568,20 @@ export default function Sidebar() {
16021568 clearSelection();
16031569 }, [clearSelection, projectScopeKey]);
16041570
1605 const handleRemoveProjectMembers = useCallback(
1606 async (projectGroup: SidebarProjectSnapshot, members: readonly SidebarProjectGroupMember[]) => {
1607 const api = readLocalApi();
1608 if (!api) return;
1609
1610 const memberKeys = new Set(members.map((member) => `${member.environmentId}:${member.id}`));
1611 const projectThreads = threads.filter((thread) =>
1612 memberKeys.has(`${thread.environmentId}:${thread.projectId}`),
1613 );
1614 const isWholeGroup = members.length === projectGroup.memberProjects.length;
1615 const singleMember = members.length === 1 ? members[0]! : null;
1616 const targetLabel = singleMember?.title ?? projectGroup.displayName;
1617 const confirmed = await settlePromise(() =>
1618 api.dialogs.confirm(
1619 projectThreads.length > 0
1620 ? [
1621 `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`,
1622 ...(singleMember
1623 ? [
1624 `Path: ${singleMember.workspaceRoot}`,
1625 ...(singleMember.environmentLabel
1626 ? [`Environment: ${singleMember.environmentLabel}`]
1627 : []),
1628 ]
1629 : [`This removes ${members.length} grouped project entries.`]),
1630 "This permanently clears conversation history for those threads.",
1631 isWholeGroup
1632 ? "This removes only the project entries, not the files on disk."
1633 : "Other entries in this grouped project are unaffected.",
1634 "This action cannot be undone.",
1635 ].join("\n")
1636 : [
1637 `Remove project "${targetLabel}"?`,
1638 ...(singleMember
1639 ? [
1640 `Path: ${singleMember.workspaceRoot}`,
1641 ...(singleMember.environmentLabel
1642 ? [`Environment: ${singleMember.environmentLabel}`]
1643 : []),
1644 ]
1645 : [`This removes ${members.length} grouped project entries.`]),
1646 isWholeGroup
1647 ? "This removes only the project entries, not the files on disk."
1648 : "Other entries in this grouped project are unaffected.",
1649 ].join("\n"),
1650 ),
1651 );
1652 if (confirmed._tag === "Failure" || !confirmed.value) return;
1653
1654 const draftStore = useComposerDraftStore.getState();
1655 let shouldNavigate = false;
1656 for (const project of members) {
1657 const memberThreads = projectThreads.filter(
1658 (thread) =>
1659 thread.environmentId === project.environmentId && thread.projectId === project.id,
1660 );
1661 const projectRef = scopeProjectRef(project.environmentId, project.id);
1662 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);
1663 const memberRemovalNeedsNavigation = shouldNavigateAfterProjectRemoval({
1664 routeTarget: routeTargetRef.current,
1665 projectThreads: memberThreads,
1666 projectDraftId: projectDraftThread?.draftId ?? null,
1667 });
1668
1669 const result = await deleteProject({
1670 environmentId: project.environmentId,
1671 input: {
1672 projectId: project.id,
1673 ...(memberThreads.length > 0 ? { force: true } : {}),
1674 },
1675 });
1676 if (result._tag === "Failure") {
1677 if (!isAtomCommandInterrupted(result)) {
1678 const error = squashAtomCommandFailure(result);
1679 toastManager.add(
1680 stackedThreadToast({
1681 type: "error",
1682 title: `Failed to remove "${project.title}"`,
1683 description: error instanceof Error ? error.message : "An error occurred.",
1684 }),
1685 );
1686 }
1687 if (shouldNavigate) {
1688 void router.navigate({ to: "/" });
1689 }
1690 return;
1691 }
1692
1693 shouldNavigate ||= memberRemovalNeedsNavigation;
1694 if (projectDraftThread) {
1695 draftStore.clearDraftThread(projectDraftThread.draftId);
1696 }
1697 draftStore.clearProjectDraftThreadId(projectRef);
1698 }
1699
1700 if (shouldNavigate) {
1701 void router.navigate({ to: "/" });
1702 }
1703 },
1704 [deleteProject, router, threads],
1705 );
1706
1707 const renameProjectMember = useCallback(
1708 async (member: SidebarProjectGroupMember, nextTitle: string) => {
1709 const title = nextTitle.trim();
1710 if (!title) {
1711 toastManager.add({ type: "warning", title: "Project title cannot be empty" });
1712 return;
1713 }
1714 if (title === member.title) return;
1715 const result = await updateProject({
1716 environmentId: member.environmentId,
1717 input: { projectId: member.id, title },
1718 });
1719 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1720 const error = squashAtomCommandFailure(result);
1721 toastManager.add(
1722 stackedThreadToast({
1723 type: "error",
1724 title: "Failed to rename project",
1725 description: error instanceof Error ? error.message : "An error occurred.",
1726 }),
1727 );
1728 }
1729 },
1730 [updateProject],
1731 );
1732
1733 const updateProjectGroupingPreference = useCallback(
1734 (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => {
1735 const overrideKey = deriveProjectGroupingOverrideKey(member);
1736 const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides };
1737 if (selection === "inherit") {
1738 delete nextOverrides[overrideKey];
1739 } else {
1740 nextOverrides[overrideKey] = selection;
1741 }
1742 updateSettings({ sidebarProjectGroupingOverrides: nextOverrides });
1743 },
1744 [projectGroupingSettings.sidebarProjectGroupingOverrides, updateSettings],
1745 );
1746
17471571 const handleProjectActions = useCallback(
17481572 (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => {
17491573 event.preventDefault();
17501574 event.stopPropagation();
17511575 setProjectScopeMenuOpen(false);
1752 window.requestAnimationFrame(() => setProjectActionsTarget(projectGroup));
1576 if (isMobile) {
1577 setOpenMobile(false);
1578 }
1579 void router.navigate({
1580 to: "/settings/projects/$projectKey",
1581 params: { projectKey: projectGroup.projectKey },
1582 });
17531583 },
1754 [],
1584 [isMobile, router, setOpenMobile],
17551585 );
17561586
17571587 // Settled threads stay in the live shell stream (settled ≠ archived), so
......@@ -3545,190 +3375,6 @@ export default function Sidebar() {
35453375 ) : null}
35463376 </SidebarGroup>
35473377 </SidebarContent>
3548 <Dialog
3549 open={projectActionsTarget !== null}
3550 onOpenChange={(open) => {
3551 if (!open) setProjectActionsTarget(null);
3552 }}
3553 >
3554 <DialogPopup className="max-w-xl">
3555 <DialogHeader className="gap-3 pb-1!">
3556 <DialogTitle className="text-balance">Project settings</DialogTitle>
3557 <DialogDescription className="sr-only">
3558 Manage project names, grouping rules, and environments.
3559 </DialogDescription>
3560 <div className="grid gap-1.5 text-base text-muted-foreground">
3561 {projectActionsTarget?.memberProjects.map((member) => (
3562 <div key={member.physicalProjectKey} className="flex min-w-0 items-center gap-3">
3563 <span className="flex min-w-0 items-center gap-1">
3564 <FolderIcon className="size-3.5 shrink-0 opacity-60" />
3565 <span className="min-w-0 truncate font-mono">{member.workspaceRoot}</span>
3566 <Button
3567 size="icon-xs"
3568 variant="ghost"
3569 className="size-4 shrink-0 rounded-sm"
3570 aria-label="Copy project path"
3571 title="Copy project path"
3572 onClick={() =>
3573 copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot })
3574 }
3575 >
3576 <CopyIcon className="size-3.5" />
3577 </Button>
3578 </span>
3579 <span className="flex min-w-0 shrink-0 items-center gap-1">
3580 <ServerIcon className="size-3.5 shrink-0 opacity-60" />
3581 <span className="min-w-0 truncate">
3582 {member.environmentLabel ?? "Current environment"}
3583 </span>
3584 </span>
3585 </div>
3586 ))}
3587 </div>
3588 </DialogHeader>
3589 <DialogPanel className="p-0">
3590 <div className="divide-y divide-border/60">
3591 {projectActionsTarget?.memberProjects.map((member) => (
3592 <section
3593 key={member.physicalProjectKey}
3594 className="grid min-w-0 gap-5 px-6 pb-5 pt-2 sm:gap-4 sm:pb-4 sm:pt-2"
3595 >
3596 <div className="grid gap-4 sm:grid-cols-2 sm:gap-3">
3597 <label className="grid min-w-0 gap-1.5">
3598 <span className="font-medium text-foreground">Project name</span>
3599 <Input
3600 key={`${member.physicalProjectKey}:${member.title}`}
3601 aria-label={`Project name in ${member.environmentLabel ?? "current environment"}`}
3602 defaultValue={member.title}
3603 onBlur={(event) => {
3604 void renameProjectMember(member, event.currentTarget.value);
3605 }}
3606 onKeyDown={(event) => {
3607 if (event.key === "Enter") event.currentTarget.blur();
3608 }}
3609 />
3610 </label>
3611 <label className="grid min-w-0 gap-1.5">
3612 <span className="font-medium text-foreground">Grouping rule</span>
3613 <Select
3614 value={
3615 projectGroupingSettings.sidebarProjectGroupingOverrides?.[
3616 deriveProjectGroupingOverrideKey(member)
3617 ] ?? "inherit"
3618 }
3619 onValueChange={(value) => {
3620 if (
3621 value === "inherit" ||
3622 value === "repository" ||
3623 value === "repository_path" ||
3624 value === "separate"
3625 ) {
3626 updateProjectGroupingPreference(member, value);
3627 }
3628 }}
3629 >
3630 <SelectTrigger
3631 className="w-full sm:min-h-7.5"
3632 aria-label={`Grouping rule for ${member.environmentLabel ?? "current environment"}`}
3633 >
3634 <SelectValue>
3635 {(() => {
3636 const selection =
3637 projectGroupingSettings.sidebarProjectGroupingOverrides?.[
3638 deriveProjectGroupingOverrideKey(member)
3639 ] ?? "inherit";
3640 return selection === "inherit"
3641 ? `Default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})`
3642 : PROJECT_GROUPING_MODE_LABELS[selection];
3643 })()}
3644 </SelectValue>
3645 </SelectTrigger>
3646 <SelectPopup align="start" alignItemWithTrigger={false}>
3647 <SelectItem hideIndicator value="inherit">
3648 Use global default
3649 </SelectItem>
3650 <SelectItem hideIndicator value="repository">
3651 {PROJECT_GROUPING_MODE_LABELS.repository}
3652 </SelectItem>
3653 <SelectItem hideIndicator value="repository_path">
3654 {PROJECT_GROUPING_MODE_LABELS.repository_path}
3655 </SelectItem>
3656 <SelectItem hideIndicator value="separate">
3657 {PROJECT_GROUPING_MODE_LABELS.separate}
3658 </SelectItem>
3659 </SelectPopup>
3660 </Select>
3661 </label>
3662 </div>
3663 {projectActionsTarget.memberProjects.length > 1 ? (
3664 <div className="flex justify-end">
3665 <Button
3666 size="sm"
3667 variant="ghost"
3668 className="text-destructive-foreground hover:bg-destructive/8 hover:text-destructive-foreground"
3669 onClick={() => {
3670 const projectGroup = projectActionsTarget;
3671 setProjectActionsTarget(null);
3672 void handleRemoveProjectMembers(projectGroup, [member]);
3673 }}
3674 >
3675 <Trash2Icon />
3676 Remove project
3677 </Button>
3678 </div>
3679 ) : null}
3680 </section>
3681 ))}
3682 </div>
3683 {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 ? (
3684 <div className="flex flex-col gap-3 border-t border-border/60 bg-muted/32 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
3685 <div className="min-w-0">
3686 <p className="text-base font-medium text-foreground sm:text-sm">
3687 Remove this project everywhere
3688 </p>
3689 <p className="text-base text-pretty text-muted-foreground sm:text-sm">
3690 Deletes all grouped entries and their conversation history.
3691 </p>
3692 </div>
3693 <Button
3694 size="sm"
3695 variant="destructive-outline"
3696 className="shrink-0"
3697 onClick={() => {
3698 const projectGroup = projectActionsTarget;
3699 setProjectActionsTarget(null);
3700 void handleRemoveProjectMembers(projectGroup, projectGroup.memberProjects);
3701 }}
3702 >
3703 <Trash2Icon />
3704 Remove all entries
3705 </Button>
3706 </div>
3707 ) : null}
3708 </DialogPanel>
3709 <DialogFooter
3710 variant="bare"
3711 className={cn(
3712 projectActionsTarget?.memberProjects.length === 1 && "sm:justify-between",
3713 )}
3714 >
3715 {projectActionsTarget?.memberProjects.length === 1 ? (
3716 <Button
3717 variant="destructive-outline"
3718 onClick={() => {
3719 const projectGroup = projectActionsTarget;
3720 setProjectActionsTarget(null);
3721 void handleRemoveProjectMembers(projectGroup, projectGroup.memberProjects);
3722 }}
3723 >
3724 <Trash2Icon />
3725 Remove project
3726 </Button>
3727 ) : null}
3728 <Button onClick={() => setProjectActionsTarget(null)}>Close</Button>
3729 </DialogFooter>
3730 </DialogPopup>
3731 </Dialog>
37323378 <SidebarChromeFooter />
37333379 </>
37343380 );
apps/web/src/components/projectScriptEditor.tsx created+415
......@@ -0,0 +1,415 @@
1import type {
2 ProjectScript,
3 ProjectScriptIcon,
4 ResolvedKeybindingsConfig,
5} from "@t3tools/contracts";
6import {
7 isAtomCommandInterrupted,
8 squashAtomCommandFailure,
9 type AtomCommandResult,
10} from "@t3tools/client-runtime/state/runtime";
11import {
12 BugIcon,
13 FlaskConicalIcon,
14 HammerIcon,
15 ListChecksIcon,
16 PlayIcon,
17 WrenchIcon,
18} from "lucide-react";
19import React, { type FormEvent, type KeyboardEvent, useEffect, useState } from "react";
20
21import {
22 keybindingValueForCommand,
23 decodeProjectScriptKeybindingRule,
24} from "~/lib/projectScriptKeybindings";
25import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic";
26import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts";
27import {
28 AlertDialog,
29 AlertDialogClose,
30 AlertDialogDescription,
31 AlertDialogFooter,
32 AlertDialogHeader,
33 AlertDialogPopup,
34 AlertDialogTitle,
35} from "./ui/alert-dialog";
36import { Button } from "./ui/button";
37import {
38 Dialog,
39 DialogDescription,
40 DialogFooter,
41 DialogHeader,
42 DialogPanel,
43 DialogPopup,
44 DialogTitle,
45} from "./ui/dialog";
46import { Input } from "./ui/input";
47import { Label } from "./ui/label";
48import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
49import { Switch } from "./ui/switch";
50import { Textarea } from "./ui/textarea";
51
52export const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [
53 { id: "play", label: "Play" },
54 { id: "test", label: "Test" },
55 { id: "lint", label: "Lint" },
56 { id: "configure", label: "Configure" },
57 { id: "build", label: "Build" },
58 { id: "debug", label: "Debug" },
59];
60
61export function ScriptIcon({
62 icon,
63 className = "size-3.5",
64}: {
65 icon: ProjectScriptIcon;
66 className?: string;
67}) {
68 if (icon === "test") return <FlaskConicalIcon className={className} />;
69 if (icon === "lint") return <ListChecksIcon className={className} />;
70 if (icon === "configure") return <WrenchIcon className={className} />;
71 if (icon === "build") return <HammerIcon className={className} />;
72 if (icon === "debug") return <BugIcon className={className} />;
73 return <PlayIcon className={className} />;
74}
75
76export interface NewProjectScriptInput {
77 name: string;
78 command: string;
79 icon: ProjectScriptIcon;
80 runOnWorktreeCreate: boolean;
81 keybinding: string | null;
82 /** Optional URL to open in the in-app preview when this script runs. */
83 previewUrl: string | null;
84 /** When true, automatically open the preview panel pointed at `previewUrl`. */
85 autoOpenPreview: boolean;
86}
87
88export type ProjectScriptActionResult = AtomCommandResult<void, unknown>;
89
90export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = {
91 name: "",
92 command: "",
93 icon: "play",
94 runOnWorktreeCreate: false,
95 keybinding: null,
96 previewUrl: null,
97 autoOpenPreview: false,
98};
99
100/** What the editor dialog should open with. `scriptId: null` means "add". */
101export interface ProjectScriptEditorRequest {
102 scriptId: string | null;
103 initial: NewProjectScriptInput;
104 /** Validation error to show immediately (e.g. a failed t3.json import). */
105 error?: string;
106}
107
108export function editorRequestForScript(
109 script: ProjectScript,
110 keybindings: ResolvedKeybindingsConfig,
111): ProjectScriptEditorRequest {
112 return {
113 scriptId: script.id,
114 initial: {
115 name: script.name,
116 command: script.command,
117 icon: script.icon,
118 runOnWorktreeCreate: script.runOnWorktreeCreate,
119 keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)),
120 previewUrl: script.previewUrl ?? null,
121 autoOpenPreview: script.autoOpenPreview ?? false,
122 },
123 };
124}
125
126/**
127 * Add/edit dialog for a project script, shared by the chat-header scripts menu
128 * and the project settings page. The parent owns which script (if any) is
129 * being edited via `request`; the dialog owns the form state and validation.
130 */
131export function ProjectScriptEditorDialog({
132 request,
133 scripts,
134 onSubmit,
135 onDelete,
136 onClose,
137}: {
138 request: ProjectScriptEditorRequest | null;
139 /** Existing scripts, used to derive a unique id for new scripts. */
140 scripts: ReadonlyArray<ProjectScript>;
141 onSubmit: (
142 scriptId: string | null,
143 input: NewProjectScriptInput,
144 ) => Promise<ProjectScriptActionResult>;
145 onDelete: (scriptId: string) => void;
146 onClose: () => void;
147}) {
148 const formId = React.useId();
149 const [name, setName] = useState("");
150 const [command, setCommand] = useState("");
151 const [icon, setIcon] = useState<ProjectScriptIcon>("play");
152 const [iconPickerOpen, setIconPickerOpen] = useState(false);
153 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
154 const [keybinding, setKeybinding] = useState("");
155 const [previewUrl, setPreviewUrl] = useState("");
156 const [autoOpenPreview, setAutoOpenPreview] = useState(false);
157 const [validationError, setValidationError] = useState<string | null>(null);
158 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
159
160 const isOpen = request !== null;
161 const isEditing = request?.scriptId != null;
162
163 // Hydrate the form whenever a new request opens the dialog.
164 useEffect(() => {
165 if (!request) return;
166 setName(request.initial.name);
167 setCommand(request.initial.command);
168 setIcon(request.initial.icon);
169 setIconPickerOpen(false);
170 setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate);
171 setKeybinding(request.initial.keybinding ?? "");
172 setPreviewUrl(request.initial.previewUrl ?? "");
173 setAutoOpenPreview(request.initial.autoOpenPreview);
174 setValidationError(request.error ?? null);
175 }, [request]);
176
177 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {
178 if (event.key === "Tab") return;
179 event.preventDefault();
180 if (event.key === "Backspace" || event.key === "Delete") {
181 setKeybinding("");
182 return;
183 }
184 const next = keybindingFromKeyboardEvent(event, navigator.platform);
185 if (!next) return;
186 setKeybinding(next);
187 };
188
189 const submit = async (event: FormEvent) => {
190 event.preventDefault();
191 if (!request) return;
192 const trimmedName = name.trim();
193 const trimmedCommand = command.trim();
194 if (trimmedName.length === 0) {
195 setValidationError("Name is required.");
196 return;
197 }
198 if (trimmedCommand.length === 0) {
199 setValidationError("Command is required.");
200 return;
201 }
202
203 setValidationError(null);
204 let payload: NewProjectScriptInput;
205 try {
206 const scriptIdForValidation =
207 request.scriptId ??
208 nextProjectScriptId(
209 trimmedName,
210 scripts.map((script) => script.id),
211 );
212 const keybindingRule = decodeProjectScriptKeybindingRule({
213 keybinding,
214 command: commandForProjectScript(scriptIdForValidation),
215 });
216 const trimmedPreviewUrl = previewUrl.trim();
217 payload = {
218 name: trimmedName,
219 command: trimmedCommand,
220 icon,
221 runOnWorktreeCreate,
222 keybinding: keybindingRule?.key ?? null,
223 previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null,
224 autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false,
225 } satisfies NewProjectScriptInput;
226 } catch (error) {
227 setValidationError(error instanceof Error ? error.message : "Failed to save action.");
228 return;
229 }
230
231 const result = await onSubmit(request.scriptId, payload);
232 if (result._tag === "Failure") {
233 if (!isAtomCommandInterrupted(result)) {
234 const error = squashAtomCommandFailure(result);
235 setValidationError(error instanceof Error ? error.message : "Failed to save action.");
236 }
237 return;
238 }
239 setIconPickerOpen(false);
240 onClose();
241 };
242
243 return (
244 <>
245 <Dialog
246 open={isOpen}
247 onOpenChange={(open) => {
248 if (!open) {
249 setIconPickerOpen(false);
250 onClose();
251 }
252 }}
253 >
254 <DialogPopup>
255 <DialogHeader>
256 <DialogTitle>{isEditing ? "Edit Action" : "Add Action"}</DialogTitle>
257 <DialogDescription>
258 Actions are project-scoped commands you can run from the top bar or keybindings.
259 </DialogDescription>
260 </DialogHeader>
261 <DialogPanel>
262 <form id={formId} className="space-y-4" onSubmit={submit}>
263 <div className="space-y-1.5">
264 <Label htmlFor="script-name">Name</Label>
265 <div className="flex items-center gap-2">
266 <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}>
267 <PopoverTrigger
268 render={
269 <Button
270 type="button"
271 variant="outline"
272 className="size-9 shrink-0 hover:bg-popover active:bg-popover data-pressed:bg-popover data-pressed:shadow-xs/5 data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:border-transparent dark:bg-white/[0.035] dark:data-pressed:before:shadow-none"
273 aria-label="Choose icon"
274 />
275 }
276 >
277 <ScriptIcon icon={icon} className="size-4.5" />
278 </PopoverTrigger>
279 <PopoverPopup align="start">
280 <div className="grid grid-cols-3 gap-2">
281 {SCRIPT_ICONS.map((entry) => {
282 const isSelected = entry.id === icon;
283 return (
284 <button
285 key={entry.id}
286 type="button"
287 className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${
288 isSelected
289 ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30"
290 : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]"
291 }`}
292 onClick={() => {
293 setIcon(entry.id);
294 setIconPickerOpen(false);
295 }}
296 >
297 <ScriptIcon icon={entry.id} className="size-4" />
298 <span>{entry.label}</span>
299 </button>
300 );
301 })}
302 </div>
303 </PopoverPopup>
304 </Popover>
305 <Input
306 id="script-name"
307 autoFocus
308 placeholder="Test"
309 value={name}
310 onChange={(event) => setName(event.target.value)}
311 />
312 </div>
313 </div>
314 <div className="space-y-1.5">
315 <Label htmlFor="script-keybinding">Keybinding</Label>
316 <Input
317 id="script-keybinding"
318 placeholder="Press shortcut"
319 value={keybinding}
320 readOnly
321 onKeyDown={captureKeybinding}
322 />
323 <p className="text-xs text-muted-foreground">
324 Press a shortcut. Use <code>Backspace</code> to clear.
325 </p>
326 </div>
327 <div className="space-y-1.5">
328 <Label htmlFor="script-command">Command</Label>
329 <Textarea
330 id="script-command"
331 placeholder="bun test"
332 value={command}
333 onChange={(event) => setCommand(event.target.value)}
334 />
335 </div>
336 <div className="space-y-1.5">
337 <Label htmlFor="script-preview-url">Preview URL (optional)</Label>
338 <Input
339 id="script-preview-url"
340 placeholder="http://localhost:5173"
341 value={previewUrl}
342 onChange={(event) => setPreviewUrl(event.target.value)}
343 />
344 <p className="text-xs text-muted-foreground">
345 Open this URL in the in-app preview when this action runs.
346 </p>
347 </div>
348 <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035]">
349 <span>Run automatically on worktree creation</span>
350 <Switch
351 checked={runOnWorktreeCreate}
352 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
353 />
354 </label>
355 <label
356 className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${
357 previewUrl.trim().length === 0 ? "opacity-60" : ""
358 }`}
359 >
360 <span>Open preview automatically when this action runs</span>
361 <Switch
362 checked={autoOpenPreview}
363 disabled={previewUrl.trim().length === 0}
364 onCheckedChange={(checked) => setAutoOpenPreview(Boolean(checked))}
365 />
366 </label>
367 {validationError && <p className="text-sm text-destructive">{validationError}</p>}
368 </form>
369 </DialogPanel>
370 <DialogFooter className="dark:border-transparent dark:bg-transparent">
371 {isEditing && (
372 <Button
373 type="button"
374 variant="destructive-outline"
375 className="mr-auto"
376 onClick={() => setDeleteConfirmOpen(true)}
377 >
378 Delete
379 </Button>
380 )}
381 <Button type="button" variant="outline" onClick={onClose}>
382 Cancel
383 </Button>
384 <Button form={formId} type="submit">
385 {isEditing ? "Save changes" : "Save action"}
386 </Button>
387 </DialogFooter>
388 </DialogPopup>
389 </Dialog>
390
391 <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
392 <AlertDialogPopup>
393 <AlertDialogHeader>
394 <AlertDialogTitle>Delete action "{name}"?</AlertDialogTitle>
395 <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
396 </AlertDialogHeader>
397 <AlertDialogFooter>
398 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
399 <Button
400 variant="destructive"
401 onClick={() => {
402 if (!request?.scriptId) return;
403 setDeleteConfirmOpen(false);
404 onClose();
405 onDelete(request.scriptId);
406 }}
407 >
408 Delete action
409 </Button>
410 </AlertDialogFooter>
411 </AlertDialogPopup>
412 </AlertDialog>
413 </>
414 );
415}
apps/web/src/components/settings/ProjectSettingsPanel.tsx created+1011
......@@ -0,0 +1,1011 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 isAtomCommandInterrupted,
4 mapAtomCommandResult,
5 settlePromise,
6 squashAtomCommandFailure,
7 type AtomCommandResult,
8} from "@t3tools/client-runtime/state/runtime";
9import { scopeProjectRef } from "@t3tools/client-runtime/environment";
10import { AsyncResult } from "effect/unstable/reactivity";
11import {
12 deriveProjectGroupingOverrideKey,
13 selectProjectGroupingSettings,
14} from "../../logicalProject";
15import type {
16 ModelSelection,
17 ProviderDriverKind,
18 SidebarProjectGroupingMode,
19 T3ProjectFileScript,
20} from "@t3tools/contracts";
21import { createModelSelection } from "@t3tools/shared/model";
22import { useLocation, useNavigate } from "@tanstack/react-router";
23import * as Cause from "effect/Cause";
24import { CopyIcon, FolderIcon, PlusIcon, ServerIcon, SettingsIcon, Trash2Icon } from "lucide-react";
25import { useCallback, useEffect, useMemo, useRef, useState } from "react";
26
27import { useComposerDraftStore } from "../../composerDraftStore";
28import { isElectron } from "../../env";
29import {
30 useClientSettings,
31 useUpdateClientSettings,
32 usePrimarySettings,
33} from "../../hooks/useSettings";
34import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
35import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";
36import { shortcutLabelForCommand } from "../../keybindings";
37import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings";
38import { readLocalApi } from "../../localApi";
39import {
40 buildProjectScript,
41 commandForProjectScript,
42 nextProjectScriptId,
43} from "../../projectScripts";
44import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings";
45import {
46 applyProviderInstanceSettings,
47 deriveProviderInstanceEntries,
48 resolveDefaultProviderModelSelection,
49 sortProviderInstanceEntries,
50} from "../../providerInstances";
51import { getCustomModelOptionsByInstance } from "../../modelSelection";
52import {
53 buildSidebarProjectSnapshots,
54 type SidebarProjectGroupMember,
55 type SidebarProjectSnapshot,
56} from "../../sidebarProjectGrouping";
57import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
58import { useProjects, useThreadShells } from "../../state/entities";
59import { projectEnvironment } from "../../state/projects";
60import {
61 primaryServerKeybindingsAtom,
62 primaryServerProvidersAtom,
63 serverEnvironment,
64} from "../../state/server";
65import { useAtomCommand } from "../../state/use-atom-command";
66import { ProviderModelPicker } from "../chat/ProviderModelPicker";
67import { TraitsPicker } from "../chat/TraitsPicker";
68import { ProjectFavicon } from "../ProjectFavicon";
69import {
70 EMPTY_PROJECT_SCRIPT_INPUT,
71 editorRequestForScript,
72 ProjectScriptEditorDialog,
73 ScriptIcon,
74 type NewProjectScriptInput,
75 type ProjectScriptEditorRequest,
76} from "../projectScriptEditor";
77import { Button } from "../ui/button";
78import { Input } from "../ui/input";
79import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
80import { stackedThreadToast, toastManager } from "../ui/toast";
81import {
82 SettingResetButton,
83 SettingsPageContainer,
84 SettingsRow,
85 SettingsSection,
86} from "./settingsLayout";
87
88export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
89 repository: "Group by repository",
90 repository_path: "Group by repository path",
91 separate: "Keep separate",
92};
93
94/** Logical project groups for the settings page, sorted by display name. */
95export function useSettingsProjectGroups(): SidebarProjectSnapshot[] {
96 const projects = useProjects();
97 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
98 const primaryEnvironmentId = usePrimaryEnvironmentId();
99 const { environments } = useEnvironments();
100 const environmentLabelById = useMemo(
101 () =>
102 new Map(
103 environments.map((environment) => [environment.environmentId, environment.label] as const),
104 ),
105 [environments],
106 );
107 return useMemo(
108 () =>
109 buildSidebarProjectSnapshots({
110 projects,
111 settings: projectGroupingSettings,
112 primaryEnvironmentId,
113 resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
114 }).sort((a, b) => a.displayName.localeCompare(b.displayName)),
115 [environmentLabelById, primaryEnvironmentId, projectGroupingSettings, projects],
116 );
117}
118
119function memberKey(member: { environmentId: string; id: string }): string {
120 return `${member.environmentId}:${member.id}`;
121}
122
123export function ProjectSettingsPanel({
124 selectedProjectKey,
125}: {
126 selectedProjectKey: string | null;
127}) {
128 const groups = useSettingsProjectGroups();
129 const navigate = useNavigate();
130 const currentHash = useLocation({ select: (location) => location.hash });
131
132 // The index route auto-selects the first project so /settings/projects is
133 // never a dead end. Hash is preserved for settings-search jumps.
134 useEffect(() => {
135 if (selectedProjectKey !== null) return;
136 const first = groups[0];
137 if (!first) return;
138 void navigate({
139 to: "/settings/projects/$projectKey",
140 params: { projectKey: first.projectKey },
141 ...(currentHash ? { hash: currentHash } : {}),
142 replace: true,
143 hashScrollIntoView: false,
144 });
145 }, [currentHash, groups, navigate, selectedProjectKey]);
146
147 const selected =
148 selectedProjectKey === null
149 ? null
150 : (groups.find((group) => group.projectKey === selectedProjectKey) ?? null);
151
152 // Remember the members of the last rendered group so a grouping-rule change
153 // (which changes the group key) can follow the project to its new group.
154 const lastSelectionRef = useRef<{ key: string; memberKeys: string[] } | null>(null);
155 useEffect(() => {
156 if (!selected) return;
157 lastSelectionRef.current = {
158 key: selected.projectKey,
159 memberKeys: selected.memberProjects.map((member) => member.physicalProjectKey),
160 };
161 }, [selected]);
162
163 // Recover when the selected key stops matching (regroup, removal, or a
164 // stale deep link) instead of parking on a dead-end message.
165 useEffect(() => {
166 if (selectedProjectKey === null || selected !== null || groups.length === 0) return;
167 const last = lastSelectionRef.current;
168 const successor =
169 last?.key === selectedProjectKey
170 ? (groups.find((group) =>
171 group.memberProjects.some((member) =>
172 last.memberKeys.includes(member.physicalProjectKey),
173 ),
174 ) ?? null)
175 : null;
176 if (successor) {
177 void navigate({
178 to: "/settings/projects/$projectKey",
179 params: { projectKey: successor.projectKey },
180 replace: true,
181 hashScrollIntoView: false,
182 });
183 } else {
184 void navigate({ to: "/settings/projects", replace: true, hashScrollIntoView: false });
185 }
186 }, [groups, navigate, selected, selectedProjectKey]);
187
188 const selectProject = useCallback(
189 (projectKey: string) => {
190 void navigate({
191 to: "/settings/projects/$projectKey",
192 params: { projectKey },
193 replace: true,
194 hashScrollIntoView: false,
195 });
196 },
197 [navigate],
198 );
199
200 return (
201 <div className="flex min-h-0 flex-1">
202 <nav
203 aria-label="Projects"
204 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"
205 >
206 {groups.map((group) => {
207 const isActive = group.projectKey === selected?.projectKey;
208 return (
209 <button
210 key={group.projectKey}
211 type="button"
212 onClick={() => selectProject(group.projectKey)}
213 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 ${
214 isActive
215 ? "bg-accent text-foreground"
216 : "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
217 }`}
218 >
219 <ProjectFavicon
220 environmentId={group.environmentId}
221 cwd={group.workspaceRoot}
222 className="size-4 shrink-0"
223 />
224 <span className="min-w-0 flex-1 truncate">{group.displayName}</span>
225 {group.groupedProjectCount > 1 ? (
226 <span className="shrink-0 text-xs text-muted-foreground/70">
227 {group.groupedProjectCount}
228 </span>
229 ) : null}
230 </button>
231 );
232 })}
233 {groups.length === 0 ? (
234 <p className="px-2 py-4 text-sm text-muted-foreground">No projects yet.</p>
235 ) : null}
236 </nav>
237 {selected ? (
238 <ProjectDetail
239 key={selected.projectKey}
240 group={selected}
241 groups={groups}
242 onSelectProject={selectProject}
243 />
244 ) : (
245 <div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground">
246 {groups.length === 0 ? "Add a project from the sidebar to configure it here." : null}
247 </div>
248 )}
249 </div>
250 );
251}
252
253function ProjectDetail({
254 group,
255 groups,
256 onSelectProject,
257}: {
258 group: SidebarProjectSnapshot;
259 groups: ReadonlyArray<SidebarProjectSnapshot>;
260 onSelectProject: (projectKey: string) => void;
261}) {
262 const navigate = useNavigate();
263 const settings = usePrimarySettings();
264 const updateClientSettings = useUpdateClientSettings();
265 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
266 const serverProviders = useAtomValue(primaryServerProvidersAtom);
267 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
268 const threads = useThreadShells();
269 const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
270 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
271 const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
272 reportFailure: false,
273 });
274 const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, {
275 reportFailure: false,
276 });
277 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
278 onCopy: ({ path }) => {
279 toastManager.add({ type: "success", title: "Path copied", description: path });
280 },
281 onError: (error) => {
282 toastManager.add(
283 stackedThreadToast({
284 type: "error",
285 title: "Failed to copy path",
286 description: error instanceof Error ? error.message : "An error occurred.",
287 }),
288 );
289 },
290 });
291
292 const representative =
293 group.memberProjects.find(
294 (member) => member.environmentId === group.environmentId && member.id === group.id,
295 ) ?? group.memberProjects[0]!;
296
297 const threadCountByMember = useMemo(() => {
298 const counts = new Map<string, number>();
299 for (const thread of threads) {
300 const key = `${thread.environmentId}:${thread.projectId}`;
301 counts.set(key, (counts.get(key) ?? 0) + 1);
302 }
303 return counts;
304 }, [threads]);
305 const groupThreadCount = group.memberProjects.reduce(
306 (total, member) => total + (threadCountByMember.get(memberKey(member)) ?? 0),
307 0,
308 );
309
310 const reportFailure = useCallback((title: string, result: AtomCommandResult<void, unknown>) => {
311 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
312 const error = squashAtomCommandFailure(result);
313 toastManager.add(
314 stackedThreadToast({
315 type: "error",
316 title,
317 description: error instanceof Error ? error.message : "An error occurred.",
318 }),
319 );
320 }, []);
321
322 // Group-shared fields (default model, scripts) live on each physical
323 // project record, so a group-level edit fans out to every member.
324 const updateAllMembers = useCallback(
325 async (
326 input: Partial<{
327 defaultModelSelection: ModelSelection | null;
328 scripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>;
329 }>,
330 failureTitle: string,
331 ): Promise<AtomCommandResult<void, unknown>> => {
332 for (const member of group.memberProjects) {
333 const result = mapAtomCommandResult(
334 await updateProject({
335 environmentId: member.environmentId,
336 input: { projectId: member.id, ...input },
337 }),
338 () => undefined,
339 );
340 if (result._tag === "Failure") {
341 // A partial fan-out is possible: earlier members already took the
342 // write. Name the environment so the user knows where it stopped.
343 reportFailure(
344 group.memberProjects.length > 1
345 ? `${failureTitle} on ${member.environmentLabel ?? "the current environment"}`
346 : failureTitle,
347 result,
348 );
349 return result;
350 }
351 }
352 return AsyncResult.success(undefined);
353 },
354 [group.memberProjects, reportFailure, updateProject],
355 );
356
357 // ----- default model -----
358 const storedSelection = representative.defaultModelSelection;
359 const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection);
360 const instanceEntries = useMemo(
361 () =>
362 sortProviderInstanceEntries(
363 applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),
364 ),
365 [serverProviders, settings],
366 );
367 const modelOptionsByInstance = useMemo(
368 () => getCustomModelOptionsByInstance(settings, serverProviders),
369 [serverProviders, settings],
370 );
371 const activeEntry = instanceEntries.find(
372 (entry) => entry.instanceId === resolvedSelection?.instanceId,
373 );
374 const setDefaultModel = useCallback(
375 (selection: ModelSelection | null) =>
376 void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"),
377 [updateAllMembers],
378 );
379
380 // ----- scripts -----
381 const scripts = representative.scripts;
382 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
383 // Script writes replace the whole array, so two overlapping writes computed
384 // from the same snapshot would drop each other's changes. One at a time.
385 const [isSavingScripts, setIsSavingScripts] = useState(false);
386 const savingScriptsRef = useRef(false);
387 const t3File = useT3ProjectFileState(representative.environmentId, representative.workspaceRoot);
388 const importableScripts = useMemo(
389 () =>
390 t3File.scripts.filter(
391 (fileScript) =>
392 !scripts.some(
393 (script) =>
394 script.command === fileScript.command ||
395 script.name.toLowerCase() === fileScript.name.toLowerCase(),
396 ),
397 ),
398 [scripts, t3File.scripts],
399 );
400
401 const persistScripts = useCallback(
402 async (
403 nextScripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>,
404 keybinding: string | null | undefined,
405 keybindingCommand: ReturnType<typeof commandForProjectScript>,
406 ): Promise<AtomCommandResult<void, unknown>> => {
407 if (savingScriptsRef.current) {
408 return AsyncResult.failure(
409 Cause.fail(new Error("Another script change is still saving. Try again.")),
410 );
411 }
412 savingScriptsRef.current = true;
413 setIsSavingScripts(true);
414 try {
415 // Captured before the write so a cleared or deleted binding can be
416 // removed from the keybindings config afterwards.
417 const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand);
418 const updateResult = await updateAllMembers(
419 { scripts: nextScripts },
420 "Failed to save scripts",
421 );
422 if (updateResult._tag === "Failure") return updateResult;
423
424 const keybindingRule = decodeProjectScriptKeybindingRule({
425 keybinding,
426 command: keybindingCommand,
427 });
428 if (!isElectron) return updateResult;
429 const environmentIds = [
430 ...new Set(group.memberProjects.map((member) => member.environmentId)),
431 ];
432 const previousTarget = previousKeybinding
433 ? decodeProjectScriptKeybindingRule({
434 keybinding: previousKeybinding,
435 command: keybindingCommand,
436 })
437 : null;
438 if (keybindingRule) {
439 // `replace` swaps the command's previous rule instead of appending a
440 // second one that would keep the old shortcut alive.
441 const input =
442 previousTarget && previousTarget.key !== keybindingRule.key
443 ? { ...keybindingRule, replace: previousTarget }
444 : keybindingRule;
445 for (const environmentId of environmentIds) {
446 const result = mapAtomCommandResult(
447 await upsertKeybinding({ environmentId, input }),
448 () => undefined,
449 );
450 if (result._tag === "Failure") {
451 reportFailure("Failed to save keybinding", result);
452 return result;
453 }
454 }
455 } else if (previousTarget) {
456 for (const environmentId of environmentIds) {
457 const result = mapAtomCommandResult(
458 await removeKeybinding({ environmentId, input: previousTarget }),
459 () => undefined,
460 );
461 if (result._tag === "Failure") {
462 reportFailure("Failed to remove keybinding", result);
463 return result;
464 }
465 }
466 }
467 return updateResult;
468 } finally {
469 savingScriptsRef.current = false;
470 setIsSavingScripts(false);
471 }
472 },
473 [
474 group.memberProjects,
475 keybindings,
476 removeKeybinding,
477 reportFailure,
478 updateAllMembers,
479 upsertKeybinding,
480 ],
481 );
482
483 const submitScript = useCallback(
484 async (
485 scriptId: string | null,
486 input: NewProjectScriptInput,
487 ): Promise<AtomCommandResult<void, unknown>> => {
488 if (scriptId === null) {
489 const nextId = nextProjectScriptId(
490 input.name,
491 scripts.map((script) => script.id),
492 );
493 const nextScript = buildProjectScript(nextId, input);
494 const nextScripts = input.runOnWorktreeCreate
495 ? [
496 ...scripts.map((script) =>
497 script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script,
498 ),
499 nextScript,
500 ]
501 : [...scripts, nextScript];
502 return persistScripts(nextScripts, input.keybinding, commandForProjectScript(nextId));
503 }
504
505 const updatedScript = buildProjectScript(scriptId, input);
506 const nextScripts = scripts.map((script) =>
507 script.id === scriptId
508 ? updatedScript
509 : input.runOnWorktreeCreate
510 ? { ...script, runOnWorktreeCreate: false }
511 : script,
512 );
513 return persistScripts(nextScripts, input.keybinding, commandForProjectScript(scriptId));
514 },
515 [persistScripts, scripts],
516 );
517
518 const deleteScript = useCallback(
519 (scriptId: string) => {
520 const nextScripts = scripts.filter((script) => script.id !== scriptId);
521 void persistScripts(nextScripts, null, commandForProjectScript(scriptId));
522 },
523 [persistScripts, scripts],
524 );
525
526 const importFileScript = useCallback(
527 async (fileScript: T3ProjectFileScript) => {
528 const payload: NewProjectScriptInput = {
529 name: fileScript.name,
530 command: fileScript.command,
531 icon: fileScript.icon ?? "play",
532 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
533 keybinding: null,
534 previewUrl: fileScript.previewUrl ?? null,
535 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
536 };
537 const result = await submitScript(null, payload);
538 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
539 const error = squashAtomCommandFailure(result);
540 setEditorRequest({
541 scriptId: null,
542 initial: payload,
543 error: error instanceof Error ? error.message : "Failed to import action.",
544 });
545 }
546 },
547 [submitScript],
548 );
549
550 // ----- checkouts -----
551 const renameMember = useCallback(
552 async (member: SidebarProjectGroupMember, nextTitle: string) => {
553 const title = nextTitle.trim();
554 if (!title) {
555 toastManager.add({ type: "warning", title: "Project title cannot be empty" });
556 return;
557 }
558 if (title === member.title) return;
559 const result = mapAtomCommandResult(
560 await updateProject({
561 environmentId: member.environmentId,
562 input: { projectId: member.id, title },
563 }),
564 () => undefined,
565 );
566 reportFailure("Failed to rename project", result);
567 },
568 [reportFailure, updateProject],
569 );
570
571 const updateGroupingPreference = useCallback(
572 (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => {
573 const overrideKey = deriveProjectGroupingOverrideKey(member);
574 const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides };
575 if (selection === "inherit") {
576 delete nextOverrides[overrideKey];
577 } else {
578 nextOverrides[overrideKey] = selection;
579 }
580 updateClientSettings({ sidebarProjectGroupingOverrides: nextOverrides });
581 },
582 [projectGroupingSettings.sidebarProjectGroupingOverrides, updateClientSettings],
583 );
584
585 const removeMembers = useCallback(
586 async (members: ReadonlyArray<SidebarProjectGroupMember>) => {
587 const api = readLocalApi();
588 if (!api) return;
589
590 const memberKeys = new Set(members.map(memberKey));
591 const projectThreads = threads.filter((thread) =>
592 memberKeys.has(`${thread.environmentId}:${thread.projectId}`),
593 );
594 const isWholeGroup = members.length === group.memberProjects.length;
595 const singleMember = members.length === 1 ? members[0]! : null;
596 const targetLabel = singleMember?.title ?? group.displayName;
597 const confirmed = await settlePromise(() =>
598 api.dialogs.confirm(
599 [
600 projectThreads.length > 0
601 ? `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`
602 : `Remove project "${targetLabel}"?`,
603 ...(singleMember
604 ? [
605 `Path: ${singleMember.workspaceRoot}`,
606 ...(singleMember.environmentLabel
607 ? [`Environment: ${singleMember.environmentLabel}`]
608 : []),
609 ]
610 : [`This removes ${members.length} grouped project entries.`]),
611 ...(projectThreads.length > 0
612 ? ["This permanently clears conversation history for those threads."]
613 : []),
614 isWholeGroup
615 ? "This removes only the project entries, not the files on disk."
616 : "Other entries in this grouped project are unaffected.",
617 "This action cannot be undone.",
618 ].join("\n"),
619 ),
620 );
621 if (confirmed._tag === "Failure" || !confirmed.value) return;
622
623 const draftStore = useComposerDraftStore.getState();
624 for (const member of members) {
625 const memberThreads = projectThreads.filter(
626 (thread) =>
627 thread.environmentId === member.environmentId && thread.projectId === member.id,
628 );
629 const result = mapAtomCommandResult(
630 await deleteProject({
631 environmentId: member.environmentId,
632 input: {
633 projectId: member.id,
634 ...(memberThreads.length > 0 ? { force: true } : {}),
635 },
636 }),
637 () => undefined,
638 );
639 if (result._tag === "Failure") {
640 reportFailure(`Failed to remove "${member.title}"`, result);
641 return;
642 }
643 const projectRef = scopeProjectRef(member.environmentId, member.id);
644 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);
645 if (projectDraftThread) {
646 draftStore.clearDraftThread(projectDraftThread.draftId);
647 }
648 draftStore.clearProjectDraftThreadId(projectRef);
649 }
650
651 if (isWholeGroup) {
652 void navigate({ to: "/settings/projects", replace: true });
653 }
654 },
655 [
656 deleteProject,
657 group.displayName,
658 group.memberProjects.length,
659 navigate,
660 reportFailure,
661 threads,
662 ],
663 );
664
665 const repositoryLine =
666 representative.repositoryIdentity?.displayName ??
667 representative.repositoryIdentity?.canonicalKey ??
668 "No git remote detected";
669 const environmentCount = new Set(group.memberProjects.map((member) => member.environmentId)).size;
670
671 return (
672 <SettingsPageContainer className="gap-10">
673 <div className="flex items-center gap-3 px-3 sm:px-4">
674 <ProjectFavicon
675 environmentId={group.environmentId}
676 cwd={group.workspaceRoot}
677 className="size-8 shrink-0"
678 />
679 <div className="min-w-0 flex-1">
680 <h1 className="truncate text-xl font-semibold tracking-[-0.02em] text-foreground">
681 {group.displayName}
682 </h1>
683 <p className="truncate text-[13px] text-muted-foreground">
684 {repositoryLine}
685 {" · "}
686 {group.memberProjects.length === 1
687 ? "1 checkout"
688 : `${group.memberProjects.length} checkouts`}
689 {environmentCount > 1 ? ` across ${environmentCount} environments` : ""}
690 {" · "}
691 {groupThreadCount === 1 ? "1 thread" : `${groupThreadCount} threads`}
692 </p>
693 </div>
694 <Select value={group.projectKey} onValueChange={(value) => onSelectProject(String(value))}>
695 <SelectTrigger className="sm:hidden" aria-label="Switch project">
696 <SelectValue>{group.displayName}</SelectValue>
697 </SelectTrigger>
698 <SelectPopup align="end" alignItemWithTrigger={false}>
699 {groups.map((candidate) => (
700 <SelectItem key={candidate.projectKey} hideIndicator value={candidate.projectKey}>
701 {candidate.displayName}
702 </SelectItem>
703 ))}
704 </SelectPopup>
705 </Select>
706 </div>
707
708 <SettingsSection title="Model">
709 <SettingsRow
710 id="project-default-model"
711 title="Default model"
712 description="New threads in this project start with this model. Applies to every checkout in this group."
713 resetAction={
714 storedSelection !== null ? (
715 <SettingResetButton
716 label="project default model"
717 onClick={() => setDefaultModel(null)}
718 />
719 ) : null
720 }
721 control={
722 resolvedSelection && activeEntry ? (
723 <div className="flex flex-wrap items-center justify-end gap-1.5">
724 <ProviderModelPicker
725 activeInstanceId={resolvedSelection.instanceId}
726 model={resolvedSelection.model}
727 lockedProvider={null}
728 instanceEntries={instanceEntries}
729 modelOptionsByInstance={modelOptionsByInstance}
730 triggerVariant="outline"
731 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
732 onInstanceModelChange={(instanceId, model) => {
733 setDefaultModel(createModelSelection(instanceId, model));
734 }}
735 />
736 <TraitsPicker
737 provider={activeEntry.driverKind as ProviderDriverKind}
738 models={activeEntry.models}
739 model={resolvedSelection.model}
740 prompt=""
741 onPromptChange={() => {}}
742 modelOptions={resolvedSelection.options ?? []}
743 allowPromptInjectedEffort={false}
744 triggerVariant="outline"
745 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
746 onModelOptionsChange={(nextOptions) => {
747 setDefaultModel(
748 createModelSelection(
749 resolvedSelection.instanceId,
750 resolvedSelection.model,
751 nextOptions,
752 ),
753 );
754 }}
755 />
756 </div>
757 ) : (
758 <span className="text-sm text-muted-foreground">No providers available</span>
759 )
760 }
761 />
762 </SettingsSection>
763
764 <SettingsSection
765 id="project-scripts"
766 title="Scripts"
767 headerAction={
768 <Button
769 size="xs"
770 variant="outline"
771 disabled={isSavingScripts}
772 onClick={() =>
773 setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })
774 }
775 >
776 <PlusIcon className="size-3.5" />
777 Add action
778 </Button>
779 }
780 >
781 {scripts.length === 0 ? (
782 <p className="px-3 py-2 text-sm text-muted-foreground sm:px-4">
783 No scripts yet. Scripts run in a project terminal from the thread top bar; one script
784 can run automatically when a worktree is created.
785 </p>
786 ) : (
787 <div className="space-y-0.5">
788 {scripts.map((script) => {
789 const shortcutLabel = shortcutLabelForCommand(
790 keybindings,
791 commandForProjectScript(script.id),
792 );
793 return (
794 <div
795 key={script.id}
796 className="group flex min-w-0 items-center gap-3 rounded-lg px-3 py-2 hover:bg-accent/50 sm:px-4"
797 >
798 <ScriptIcon
799 icon={script.icon}
800 className="size-4 shrink-0 text-muted-foreground"
801 />
802 <span className="shrink-0 text-sm font-medium text-foreground">
803 {script.name}
804 </span>
805 {script.runOnWorktreeCreate ? (
806 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] text-muted-foreground">
807 setup
808 </span>
809 ) : null}
810 {script.previewUrl ? (
811 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] text-muted-foreground max-sm:hidden">
812 preview · desktop only
813 </span>
814 ) : null}
815 <span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground/80">
816 {script.command}
817 </span>
818 {shortcutLabel ? (
819 <span className="shrink-0 text-xs text-muted-foreground">{shortcutLabel}</span>
820 ) : null}
821 <Button
822 size="icon-xs"
823 variant="ghost"
824 className="shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
825 aria-label={`Edit ${script.name}`}
826 disabled={isSavingScripts}
827 onClick={() => setEditorRequest(editorRequestForScript(script, keybindings))}
828 >
829 <SettingsIcon className="size-3.5" />
830 </Button>
831 </div>
832 );
833 })}
834 </div>
835 )}
836 {t3File.status === "invalid" ? (
837 <SettingsRow
838 title="t3.json is invalid"
839 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."
840 className="text-warning"
841 />
842 ) : null}
843 {importableScripts.length > 0 ? (
844 <SettingsRow
845 title="Import from t3.json"
846 description={`${importableScripts.length} script${importableScripts.length === 1 ? "" : "s"} declared in this repo's t3.json ${importableScripts.length === 1 ? "is" : "are"} not set up yet.`}
847 control={
848 <div className="flex flex-wrap justify-end gap-1.5">
849 {importableScripts.map((fileScript) => (
850 <Button
851 key={`${fileScript.name} ${fileScript.command}`}
852 size="xs"
853 variant="outline"
854 disabled={isSavingScripts}
855 onClick={() => void importFileScript(fileScript)}
856 >
857 <ScriptIcon icon={fileScript.icon ?? "play"} className="size-3.5" />
858 {fileScript.name}
859 </Button>
860 ))}
861 </div>
862 }
863 />
864 ) : null}
865 </SettingsSection>
866
867 <SettingsSection id="project-checkouts" title="Checkouts">
868 <div className="space-y-2 px-3 sm:px-4">
869 {group.memberProjects.map((member) => {
870 const threadCount = threadCountByMember.get(memberKey(member)) ?? 0;
871 const groupingOverride =
872 projectGroupingSettings.sidebarProjectGroupingOverrides?.[
873 deriveProjectGroupingOverrideKey(member)
874 ] ?? "inherit";
875 return (
876 <div
877 key={member.physicalProjectKey}
878 className="space-y-3 rounded-lg border border-border/50 p-3"
879 >
880 <div className="flex min-w-0 items-center gap-2">
881 <ServerIcon className="size-3.5 shrink-0 text-muted-foreground" />
882 <span className="min-w-0 truncate text-sm font-medium text-foreground">
883 {member.environmentLabel ?? "Current environment"}
884 </span>
885 <span className="ms-auto shrink-0 text-xs text-muted-foreground">
886 {threadCount === 1 ? "1 thread" : `${threadCount} threads`}
887 </span>
888 {group.memberProjects.length > 1 ? (
889 <Button
890 size="icon-xs"
891 variant="ghost"
892 className="shrink-0 text-muted-foreground hover:text-destructive-foreground"
893 aria-label={`Remove checkout ${member.workspaceRoot}`}
894 onClick={() => void removeMembers([member])}
895 >
896 <Trash2Icon className="size-3.5" />
897 </Button>
898 ) : null}
899 </div>
900 <div className="flex min-w-0 items-center gap-1.5">
901 <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/70" />
902 <span className="min-w-0 truncate font-mono text-xs text-muted-foreground">
903 {member.workspaceRoot}
904 </span>
905 <Button
906 size="icon-xs"
907 variant="ghost"
908 className="size-4 shrink-0 rounded-sm"
909 aria-label="Copy project path"
910 onClick={() =>
911 copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot })
912 }
913 >
914 <CopyIcon className="size-3" />
915 </Button>
916 </div>
917 <div className="grid gap-3 sm:grid-cols-2">
918 <label className="grid min-w-0 gap-1.5">
919 <span className="text-xs font-medium text-muted-foreground">Name</span>
920 <Input
921 key={`${member.physicalProjectKey}:${member.title}`}
922 aria-label={`Project name in ${member.environmentLabel ?? "current environment"}`}
923 defaultValue={member.title}
924 onBlur={(event) => {
925 void renameMember(member, event.currentTarget.value);
926 }}
927 onKeyDown={(event) => {
928 if (event.key === "Enter") event.currentTarget.blur();
929 }}
930 />
931 </label>
932 <label className="grid min-w-0 gap-1.5">
933 <span className="text-xs font-medium text-muted-foreground">Grouping rule</span>
934 <Select
935 value={groupingOverride}
936 onValueChange={(value) => {
937 if (
938 value === "inherit" ||
939 value === "repository" ||
940 value === "repository_path" ||
941 value === "separate"
942 ) {
943 updateGroupingPreference(member, value);
944 }
945 }}
946 >
947 <SelectTrigger
948 className="w-full"
949 aria-label={`Grouping rule for ${member.environmentLabel ?? "current environment"}`}
950 >
951 <SelectValue>
952 {groupingOverride === "inherit"
953 ? `Default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})`
954 : PROJECT_GROUPING_MODE_LABELS[groupingOverride]}
955 </SelectValue>
956 </SelectTrigger>
957 <SelectPopup align="start" alignItemWithTrigger={false}>
958 <SelectItem hideIndicator value="inherit">
959 Use global default
960 </SelectItem>
961 <SelectItem hideIndicator value="repository">
962 {PROJECT_GROUPING_MODE_LABELS.repository}
963 </SelectItem>
964 <SelectItem hideIndicator value="repository_path">
965 {PROJECT_GROUPING_MODE_LABELS.repository_path}
966 </SelectItem>
967 <SelectItem hideIndicator value="separate">
968 {PROJECT_GROUPING_MODE_LABELS.separate}
969 </SelectItem>
970 </SelectPopup>
971 </Select>
972 </label>
973 </div>
974 </div>
975 );
976 })}
977 </div>
978 </SettingsSection>
979
980 <SettingsSection title="Danger">
981 <SettingsRow
982 title={
983 group.memberProjects.length > 1 ? "Remove this project everywhere" : "Remove project"
984 }
985 description={
986 group.memberProjects.length > 1
987 ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.`
988 : "Deletes the project entry and its threads. Files on disk are not touched."
989 }
990 control={
991 <Button
992 variant="destructive-outline"
993 onClick={() => void removeMembers(group.memberProjects)}
994 >
995 <Trash2Icon />
996 {group.memberProjects.length > 1 ? "Remove all entries" : "Remove project"}
997 </Button>
998 }
999 />
1000 </SettingsSection>
1001
1002 <ProjectScriptEditorDialog
1003 request={editorRequest}
1004 scripts={scripts}
1005 onSubmit={submitScript}
1006 onDelete={deleteScript}
1007 onClose={() => setEditorRequest(null)}
1008 />
1009 </SettingsPageContainer>
1010 );
1011}
apps/web/src/components/settings/SettingsSidebarNav.tsx+5-1
......@@ -11,6 +11,7 @@ import {
1111 ArchiveIcon,
1212 ArrowLeftIcon,
1313 BotIcon,
14 FolderIcon,
1415 GitBranchIcon,
1516 KeyboardIcon,
1617 Link2Icon,
......@@ -48,6 +49,7 @@ const SETTINGS_SECTION_ICONS: Readonly<
4849 "/settings/general": Settings2Icon,
4950 "/settings/appearance": PaletteIcon,
5051 "/settings/keybindings": KeyboardIcon,
52 "/settings/projects": FolderIcon,
5153 "/settings/providers": BotIcon,
5254 "/settings/source-control": GitBranchIcon,
5355 "/settings/connections": Link2Icon,
......@@ -279,7 +281,9 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
279281 ))
280282 : SETTINGS_NAV_ITEMS.map((item) => {
281283 const Icon = item.icon;
282 const isActive = pathname === item.to;
284 // Prefix match keeps the section active on nested routes
285 // like /settings/projects/$projectKey.
286 const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`);
283287 return (
284288 <SidebarMenuItem key={item.to}>
285289 <SidebarMenuButton
apps/web/src/components/settings/settingsSearch.ts+22
......@@ -2,6 +2,7 @@ export type SettingsPath =
22 | "/settings/general"
33 | "/settings/appearance"
44 | "/settings/keybindings"
5 | "/settings/projects"
56 | "/settings/providers"
67 | "/settings/source-control"
78 | "/settings/connections"
......@@ -22,6 +23,7 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
2223 "/settings/general": "General",
2324 "/settings/appearance": "Appearance",
2425 "/settings/keybindings": "Keybindings",
26 "/settings/projects": "Projects",
2527 "/settings/providers": "Providers",
2628 "/settings/source-control": "Source Control",
2729 "/settings/connections": "Connections",
......@@ -174,6 +176,26 @@ export const SETTINGS_SEARCH_ITEMS = [
174176 title: "Keybindings",
175177 to: "/settings/keybindings",
176178 },
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-scripts",
191 title: "Project scripts",
192 to: "/settings/projects",
193 },
194 {
195 id: "project-checkouts",
196 title: "Project checkouts",
197 to: "/settings/projects",
198 },
177199 {
178200 id: "providers",
179201 title: "Providers",
apps/web/src/hooks/useT3ProjectFileScripts.ts+36-8
......@@ -14,6 +14,41 @@ const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson);
1414
1515const NO_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];
1616
17export interface T3ProjectFileState {
18 /**
19 * - `valid`: t3.json exists and decoded.
20 * - `invalid`: t3.json exists but fails to decode (the server then ignores
21 * the whole file, including `iconPath` and every script).
22 * - `missing`: no readable t3.json at the workspace root.
23 * - `loading`: the file query has not settled yet.
24 */
25 status: "loading" | "missing" | "invalid" | "valid";
26 scripts: ReadonlyArray<T3ProjectFileScript>;
27}
28
29/**
30 * Decoded state of the project's checked-in `t3.json`, including whether the
31 * file exists but is broken — which the runtime otherwise swallows silently.
32 */
33export function useT3ProjectFileState(
34 environmentId: EnvironmentId,
35 cwd: string | null,
36): T3ProjectFileState {
37 const query = useProjectFileQuery(environmentId, cwd ?? "", T3_PROJECT_FILE_NAME, cwd !== null);
38 const contents = query.data && !query.data.truncated ? query.data.contents : null;
39 const isPending = query.isPending;
40 return useMemo(() => {
41 if (contents === null) {
42 return { status: isPending ? "loading" : "missing", scripts: NO_SCRIPTS } as const;
43 }
44 const decoded = decodeT3ProjectFile(contents);
45 if (Exit.isFailure(decoded)) {
46 return { status: "invalid", scripts: NO_SCRIPTS } as const;
47 }
48 return { status: "valid", scripts: decoded.value.scripts ?? NO_SCRIPTS } as const;
49 }, [contents, isPending]);
50}
51
1752/**
1853 * Scripts declared in the project's checked-in `t3.json`, offered in the
1954 * scripts menu for import. Missing, truncated, or invalid files resolve to
......@@ -23,12 +58,5 @@ export function useT3ProjectFileScripts(
2358 environmentId: EnvironmentId,
2459 cwd: string | null,
2560): ReadonlyArray<T3ProjectFileScript> {
26 const query = useProjectFileQuery(environmentId, cwd ?? "", T3_PROJECT_FILE_NAME, cwd !== null);
27 const contents = query.data && !query.data.truncated ? query.data.contents : null;
28 return useMemo(() => {
29 if (contents === null) return NO_SCRIPTS;
30 const decoded = decodeT3ProjectFile(contents);
31 if (Exit.isFailure(decoded)) return NO_SCRIPTS;
32 return decoded.value.scripts ?? NO_SCRIPTS;
33 }, [contents]);
61 return useT3ProjectFileState(environmentId, cwd).scripts;
3462}
apps/web/src/routeTree.gen.ts+43
......@@ -17,6 +17,7 @@ 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'
2021import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings'
2122import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
2223import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
......@@ -24,6 +25,7 @@ import { Route as SettingsConnectionsRouteImport } from './routes/settings.conne
2425import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
2526import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance'
2627import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback'
28import { Route as SettingsProjectsProjectKeyRouteImport } from './routes/settings.projects_.$projectKey'
2729import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
2830import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
2931
......@@ -66,6 +68,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({
6668 path: '/providers',
6769 getParentRoute: () => SettingsRoute,
6870} as any)
71const SettingsProjectsRoute = SettingsProjectsRouteImport.update({
72 id: '/projects',
73 path: '/projects',
74 getParentRoute: () => SettingsRoute,
75} as any)
6976const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({
7077 id: '/keybindings',
7178 path: '/keybindings',
......@@ -101,6 +108,12 @@ const ConnectCallbackRoute = ConnectCallbackRouteImport.update({
101108 path: '/connect/callback',
102109 getParentRoute: () => rootRouteImport,
103110} as any)
111const SettingsProjectsProjectKeyRoute =
112 SettingsProjectsProjectKeyRouteImport.update({
113 id: '/projects_/$projectKey',
114 path: '/projects/$projectKey',
115 getParentRoute: () => SettingsRoute,
116 } as any)
104117const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({
105118 id: '/draft/$draftId',
106119 path: '/draft/$draftId',
......@@ -126,10 +139,12 @@ export interface FileRoutesByFullPath {
126139 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
127140 '/settings/general': typeof SettingsGeneralRoute
128141 '/settings/keybindings': typeof SettingsKeybindingsRoute
142 '/settings/projects': typeof SettingsProjectsRoute
129143 '/settings/providers': typeof SettingsProvidersRoute
130144 '/settings/source-control': typeof SettingsSourceControlRoute
131145 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
132146 '/draft/$draftId': typeof ChatDraftDraftIdRoute
147 '/settings/projects/$projectKey': typeof SettingsProjectsProjectKeyRoute
133148}
134149export interface FileRoutesByTo {
135150 '/connect': typeof ConnectRoute
......@@ -143,11 +158,13 @@ export interface FileRoutesByTo {
143158 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
144159 '/settings/general': typeof SettingsGeneralRoute
145160 '/settings/keybindings': typeof SettingsKeybindingsRoute
161 '/settings/projects': typeof SettingsProjectsRoute
146162 '/settings/providers': typeof SettingsProvidersRoute
147163 '/settings/source-control': typeof SettingsSourceControlRoute
148164 '/': typeof ChatIndexRoute
149165 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
150166 '/draft/$draftId': typeof ChatDraftDraftIdRoute
167 '/settings/projects/$projectKey': typeof SettingsProjectsProjectKeyRoute
151168}
152169export interface FileRoutesById {
153170 __root__: typeof rootRouteImport
......@@ -163,11 +180,13 @@ export interface FileRoutesById {
163180 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
164181 '/settings/general': typeof SettingsGeneralRoute
165182 '/settings/keybindings': typeof SettingsKeybindingsRoute
183 '/settings/projects': typeof SettingsProjectsRoute
166184 '/settings/providers': typeof SettingsProvidersRoute
167185 '/settings/source-control': typeof SettingsSourceControlRoute
168186 '/_chat/': typeof ChatIndexRoute
169187 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
170188 '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute
189 '/settings/projects_/$projectKey': typeof SettingsProjectsProjectKeyRoute
171190}
172191export interface FileRouteTypes {
173192 fileRoutesByFullPath: FileRoutesByFullPath
......@@ -184,10 +203,12 @@ export interface FileRouteTypes {
184203 | '/settings/diagnostics'
185204 | '/settings/general'
186205 | '/settings/keybindings'
206 | '/settings/projects'
187207 | '/settings/providers'
188208 | '/settings/source-control'
189209 | '/$environmentId/$threadId'
190210 | '/draft/$draftId'
211 | '/settings/projects/$projectKey'
191212 fileRoutesByTo: FileRoutesByTo
192213 to:
193214 | '/connect'
......@@ -201,11 +222,13 @@ export interface FileRouteTypes {
201222 | '/settings/diagnostics'
202223 | '/settings/general'
203224 | '/settings/keybindings'
225 | '/settings/projects'
204226 | '/settings/providers'
205227 | '/settings/source-control'
206228 | '/'
207229 | '/$environmentId/$threadId'
208230 | '/draft/$draftId'
231 | '/settings/projects/$projectKey'
209232 id:
210233 | '__root__'
211234 | '/_chat'
......@@ -220,11 +243,13 @@ export interface FileRouteTypes {
220243 | '/settings/diagnostics'
221244 | '/settings/general'
222245 | '/settings/keybindings'
246 | '/settings/projects'
223247 | '/settings/providers'
224248 | '/settings/source-control'
225249 | '/_chat/'
226250 | '/_chat/$environmentId/$threadId'
227251 | '/_chat/draft/$draftId'
252 | '/settings/projects_/$projectKey'
228253 fileRoutesById: FileRoutesById
229254}
230255export interface RootRouteChildren {
......@@ -294,6 +319,13 @@ declare module '@tanstack/react-router' {
294319 preLoaderRoute: typeof SettingsProvidersRouteImport
295320 parentRoute: typeof SettingsRoute
296321 }
322 '/settings/projects': {
323 id: '/settings/projects'
324 path: '/projects'
325 fullPath: '/settings/projects'
326 preLoaderRoute: typeof SettingsProjectsRouteImport
327 parentRoute: typeof SettingsRoute
328 }
297329 '/settings/keybindings': {
298330 id: '/settings/keybindings'
299331 path: '/keybindings'
......@@ -343,6 +375,13 @@ declare module '@tanstack/react-router' {
343375 preLoaderRoute: typeof ConnectCallbackRouteImport
344376 parentRoute: typeof rootRouteImport
345377 }
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 }
346385 '/_chat/draft/$draftId': {
347386 id: '/_chat/draft/$draftId'
348387 path: '/draft/$draftId'
......@@ -381,8 +420,10 @@ interface SettingsRouteChildren {
381420 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute
382421 SettingsGeneralRoute: typeof SettingsGeneralRoute
383422 SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute
423 SettingsProjectsRoute: typeof SettingsProjectsRoute
384424 SettingsProvidersRoute: typeof SettingsProvidersRoute
385425 SettingsSourceControlRoute: typeof SettingsSourceControlRoute
426 SettingsProjectsProjectKeyRoute: typeof SettingsProjectsProjectKeyRoute
386427}
387428
388429const SettingsRouteChildren: SettingsRouteChildren = {
......@@ -392,8 +433,10 @@ const SettingsRouteChildren: SettingsRouteChildren = {
392433 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,
393434 SettingsGeneralRoute: SettingsGeneralRoute,
394435 SettingsKeybindingsRoute: SettingsKeybindingsRoute,
436 SettingsProjectsRoute: SettingsProjectsRoute,
395437 SettingsProvidersRoute: SettingsProvidersRoute,
396438 SettingsSourceControlRoute: SettingsSourceControlRoute,
439 SettingsProjectsProjectKeyRoute: SettingsProjectsProjectKeyRoute,
397440}
398441
399442const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
apps/web/src/routes/settings.projects.tsx created+11
......@@ -0,0 +1,11 @@
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 created+12
......@@ -0,0 +1,12 @@
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});