History

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

Theo Browne committed 3 months ago

Commit 288d8e31 parent 5208bde11 files · +1609 −756Browse files

Unified · Split

11 files
  1. apps/web/src/components/CommandPalette.tsx +11 −0
  2. apps/web/src/components/ProjectScriptsControl.tsx +32 −382
  3. apps/web/src/components/Sidebar.tsx +11 −365
  4. apps/web/src/components/projectScriptEditor.tsx +415 −0
  5. apps/web/src/components/settings/ProjectSettingsPanel.tsx +1011 −0
  6. apps/web/src/components/settings/SettingsSidebarNav.tsx +5 −1
  7. apps/web/src/components/settings/settingsSearch.ts +22 −0
  8. apps/web/src/hooks/useT3ProjectFileScripts.ts +36 −8
  9. apps/web/src/routeTree.gen.ts +43 −0
  10. apps/web/src/routes/settings.projects.tsx +11 −0
  11. apps/web/src/routes/settings.projects_.$projectKey.tsx +12 −0
apps/web/src/components/CommandPalette.tsx+11−0
@@ -1505,6 +1505,17 @@ function OpenCommandPaletteDialog(props: { · 1504 unmodified lines
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 @@
11 import type {
22 ProjectScript,
3− ProjectScriptIcon,
43 ResolvedKeybindingsConfig,
54 T3ProjectFileScript,
65 } from "@t3tools/contracts";
76 import {
87 isAtomCommandInterrupted,
98 squashAtomCommandFailure,
10− type AtomCommandResult,
119 } from "@t3tools/client-runtime/state/runtime";
12−import {
13− BugIcon,
14− ChevronDownIcon,
15− DownloadIcon,
16− FlaskConicalIcon,
17− HammerIcon,
18− ListChecksIcon,
19− PlayIcon,
20− PlusIcon,
21− SettingsIcon,
22− WrenchIcon,
23−} from "lucide-react";
24−import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react";
10+import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon } from "lucide-react";
11+import { useCallback, useMemo, useState } from "react";
2512
26−import {
27− keybindingValueForCommand,
28− decodeProjectScriptKeybindingRule,
29−} from "~/lib/projectScriptKeybindings";
30−import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic";
31−import {
32− commandForProjectScript,
33− nextProjectScriptId,
34− primaryProjectScript,
35−} from "~/projectScripts";
13+import { commandForProjectScript, primaryProjectScript } from "~/projectScripts";
3614 import { shortcutLabelForCommand } from "~/keybindings";
3715 import {
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";
4624 import { Button } from "./ui/button";
47−import {
48− Dialog,
49− DialogDescription,
50− DialogFooter,
51− DialogHeader,
52− DialogPanel,
53− DialogPopup,
54− DialogTitle,
55−} from "./ui/dialog";
5625 import { Group, GroupSeparator } from "./ui/group";
57−import { Input } from "./ui/input";
58−import { Label } from "./ui/label";
5926 import {
6027 Menu,
6128 MenuGroup,
@@ -66,48 +33,9 @@ import { · 4 unmodified lines
6633 MenuShortcut,
6734 MenuTrigger,
6835 } from "./ui/menu";
69−import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
70−import { Switch } from "./ui/switch";
71−import { Textarea } from "./ui/textarea";
7236 import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
7337
74−const 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−
83−function 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−
98−export 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−
110−export type ProjectScriptActionResult = AtomCommandResult<void, unknown>;
38+export type { NewProjectScriptInput, ProjectScriptActionResult };
11139
11240 const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];
11341
@@ -136,23 +64,11 @@ export default function ProjectScriptsControl({ · 22 unmodified lines
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({ · 14 unmodified lines
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({ · 10 unmodified lines
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({ · 154 unmodified lines
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 { · 27 unmodified lines
2828 scopeThreadRef,
2929 scopedThreadKey,
3030 } from "@t3tools/client-runtime/environment";
31−import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
31+import type { ScopedThreadRef } from "@t3tools/contracts";
3232 import type { TimestampFormat } from "@t3tools/contracts/settings";
3333 import {
3434 AlarmClockIcon,
@@ -39,7 +39,6 @@ import { · 4 unmodified lines
3939 CircleCheckIcon,
4040 CircleDashedIcon,
4141 ClockIcon,
42− CopyIcon,
4342 FolderIcon,
4443 FolderPlusIcon,
4544 GitBranchIcon,
@@ -51,7 +50,6 @@ import { · 5 unmodified lines
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 · 31 unmodified lines
8987 import { isMacPlatform } from "~/lib/utils";
9088 import { useOpenPrLink } from "../lib/openPullRequestLink";
9189 import { readLocalApi } from "../localApi";
92−import {
93− deriveProjectGroupingOverrideKey,
94− getProjectOrderKey,
95− selectProjectGroupingSettings,
96−} from "../logicalProject";
90+import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
9791 import {
9892 buildSidebarProjectSnapshots,
99− type SidebarProjectGroupMember,
10093 type SidebarProjectSnapshot,
10194 } from "../sidebarProjectGrouping";
10295 import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
@@ -105,7 +98,7 @@ import { useThreadActions } from "../hooks/useThreadActions"; · 2 unmodified lines
10598 import { useHandleNewThread } from "../hooks/useHandleNewThread";
10699 import { openCommandPalette } from "../commandPaletteBus";
107100 import { startNewThreadFromContext } from "../lib/chatThreadActions";
108−import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings";
101+import { useClientSettings } from "../hooks/useSettings";
109102 import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
110103 import { useNowMinute } from "../hooks/useNowMinute";
111104 import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
@@ -113,7 +106,6 @@ import { useProjects, useThreadShells } from "../state/entities"; · 1 unmodified line
113106 import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server";
114107 import { vcsEnvironment } from "../state/vcs";
115108 import { threadEnvironment } from "../state/threads";
116−import { projectEnvironment } from "../state/projects";
117109 import { useEnvironmentQuery } from "../state/query";
118110 import { useAtomCommand } from "../state/use-atom-command";
119111 import {
@@ -138,7 +130,6 @@ import { · 18 unmodified lines
138130 resolveSidebarThreadStatus,
139131 searchSidebarThreadsByTitle,
140132 resolveWorkingStartedAt,
141− shouldNavigateAfterProjectRemoval,
142133 sortLogicalProjectsForSidebar,
143134 sortPinnedThreadsForSidebar,
144135 sortSettledThreadsForSidebar,
@@ -166,18 +157,8 @@ import { primaryServerProvidersAtom } from "../state/server"; · 21 unmodified lines
166157 import { useThreadRunningTerminalIds } from "../state/terminalSessions";
167158 import { stackedThreadToast, toastManager } from "./ui/toast";
168159 import { Button } from "./ui/button";
169−import {
170− Dialog,
171− DialogDescription,
172− DialogFooter,
173− DialogHeader,
174− DialogPanel,
175− DialogPopup,
176− DialogTitle,
177−} from "./ui/dialog";
178160 import { Input } from "./ui/input";
179161 import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
180−import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
181162 import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";
182163 import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
183164 import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
@@ -188,11 +169,6 @@ import { useComposerDraftStore } from "../composerDraftStore"; · 4 unmodified lines
188169 // stays behind an explicit Show more.
189170 const SETTLED_TAIL_INITIAL_COUNT = 10;
190171 const SETTLED_TAIL_PAGE_COUNT = 25;
191−const 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
197173 function compactSidebarTimeLabel(label: string): string {
198174 if (label === "just now") return "now";
@@ -1379,13 +1355,6 @@ export default function Sidebar() { · 1180 unmodified lines
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() { · 31 unmodified lines
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() { · 170 unmodified lines
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() { · 1787 unmodified lines
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+415−0
@@ -0,0 +1,415 @@
1+import type {
2+ ProjectScript,
3+ ProjectScriptIcon,
4+ ResolvedKeybindingsConfig,
5+} from "@t3tools/contracts";
6+import {
7+ isAtomCommandInterrupted,
8+ squashAtomCommandFailure,
9+ type AtomCommandResult,
10+} from "@t3tools/client-runtime/state/runtime";
11+import {
12+ BugIcon,
13+ FlaskConicalIcon,
14+ HammerIcon,
15+ ListChecksIcon,
16+ PlayIcon,
17+ WrenchIcon,
18+} from "lucide-react";
19+import React, { type FormEvent, type KeyboardEvent, useEffect, useState } from "react";
20+
21+import {
22+ keybindingValueForCommand,
23+ decodeProjectScriptKeybindingRule,
24+} from "~/lib/projectScriptKeybindings";
25+import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic";
26+import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts";
27+import {
28+ AlertDialog,
29+ AlertDialogClose,
30+ AlertDialogDescription,
31+ AlertDialogFooter,
32+ AlertDialogHeader,
33+ AlertDialogPopup,
34+ AlertDialogTitle,
35+} from "./ui/alert-dialog";
36+import { Button } from "./ui/button";
37+import {
38+ Dialog,
39+ DialogDescription,
40+ DialogFooter,
41+ DialogHeader,
42+ DialogPanel,
43+ DialogPopup,
44+ DialogTitle,
45+} from "./ui/dialog";
46+import { Input } from "./ui/input";
47+import { Label } from "./ui/label";
48+import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
49+import { Switch } from "./ui/switch";
50+import { Textarea } from "./ui/textarea";
51+
52+export 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+
61+export 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+
76+export 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+
88+export type ProjectScriptActionResult = AtomCommandResult<void, unknown>;
89+
90+export 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". */
101+export 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+
108+export 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+ */
131+export 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+1011−0
@@ -0,0 +1,1011 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import {
3+ isAtomCommandInterrupted,
4+ mapAtomCommandResult,
5+ settlePromise,
6+ squashAtomCommandFailure,
7+ type AtomCommandResult,
8+} from "@t3tools/client-runtime/state/runtime";
9+import { scopeProjectRef } from "@t3tools/client-runtime/environment";
10+import { AsyncResult } from "effect/unstable/reactivity";
11+import {
12+ deriveProjectGroupingOverrideKey,
13+ selectProjectGroupingSettings,
14+} from "../../logicalProject";
15+import type {
16+ ModelSelection,
17+ ProviderDriverKind,
18+ SidebarProjectGroupingMode,
19+ T3ProjectFileScript,
20+} from "@t3tools/contracts";
21+import { createModelSelection } from "@t3tools/shared/model";
22+import { useLocation, useNavigate } from "@tanstack/react-router";
23+import * as Cause from "effect/Cause";
24+import { CopyIcon, FolderIcon, PlusIcon, ServerIcon, SettingsIcon, Trash2Icon } from "lucide-react";
25+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
26+
27+import { useComposerDraftStore } from "../../composerDraftStore";
28+import { isElectron } from "../../env";
29+import {
30+ useClientSettings,
31+ useUpdateClientSettings,
32+ usePrimarySettings,
33+} from "../../hooks/useSettings";
34+import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
35+import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";
36+import { shortcutLabelForCommand } from "../../keybindings";
37+import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings";
38+import { readLocalApi } from "../../localApi";
39+import {
40+ buildProjectScript,
41+ commandForProjectScript,
42+ nextProjectScriptId,
43+} from "../../projectScripts";
44+import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings";
45+import {
46+ applyProviderInstanceSettings,
47+ deriveProviderInstanceEntries,
48+ resolveDefaultProviderModelSelection,
49+ sortProviderInstanceEntries,
50+} from "../../providerInstances";
51+import { getCustomModelOptionsByInstance } from "../../modelSelection";
52+import {
53+ buildSidebarProjectSnapshots,
54+ type SidebarProjectGroupMember,
55+ type SidebarProjectSnapshot,
56+} from "../../sidebarProjectGrouping";
57+import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
58+import { useProjects, useThreadShells } from "../../state/entities";
59+import { projectEnvironment } from "../../state/projects";
60+import {
61+ primaryServerKeybindingsAtom,
62+ primaryServerProvidersAtom,
63+ serverEnvironment,
64+} from "../../state/server";
65+import { useAtomCommand } from "../../state/use-atom-command";
66+import { ProviderModelPicker } from "../chat/ProviderModelPicker";
67+import { TraitsPicker } from "../chat/TraitsPicker";
68+import { ProjectFavicon } from "../ProjectFavicon";
69+import {
70+ EMPTY_PROJECT_SCRIPT_INPUT,
71+ editorRequestForScript,
72+ ProjectScriptEditorDialog,
73+ ScriptIcon,
74+ type NewProjectScriptInput,
75+ type ProjectScriptEditorRequest,
76+} from "../projectScriptEditor";
77+import { Button } from "../ui/button";
78+import { Input } from "../ui/input";
79+import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
80+import { stackedThreadToast, toastManager } from "../ui/toast";
81+import {
82+ SettingResetButton,
83+ SettingsPageContainer,
84+ SettingsRow,
85+ SettingsSection,
86+} from "./settingsLayout";
87+
88+export 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. */
95+export 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+
119+function memberKey(member: { environmentId: string; id: string }): string {
120+ return `${member.environmentId}:${member.id}`;
121+}
122+
123+export 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+
253+function 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 { · 10 unmodified lines
1111 ArchiveIcon,
1212 ArrowLeftIcon,
1313 BotIcon,
14+ FolderIcon,
1415 GitBranchIcon,
1516 KeyboardIcon,
1617 Link2Icon,
@@ -48,6 +49,7 @@ const SETTINGS_SECTION_ICONS: Readonly< · 31 unmodified lines
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 }) { · 225 unmodified lines
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−0
@@ -2,6 +2,7 @@ export type SettingsPath = · 1 unmodified line
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>> = { · 14 unmodified lines
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 = [ · 146 unmodified lines
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); · 13 unmodified lines
1414
1515 const NO_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];
1616
17+export 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+ */
33+export 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( · 3 unmodified lines
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−0
@@ -17,6 +17,7 @@ import { Route as ChatRouteImport } from './routes/_chat' · 16 unmodified lines
1717 import { Route as ChatIndexRouteImport } from './routes/_chat.index'
1818 import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
1919 import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'
20+import { Route as SettingsProjectsRouteImport } from './routes/settings.projects'
2021 import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings'
2122 import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
2223 import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
@@ -24,6 +25,7 @@ import { Route as SettingsConnectionsRouteImport } from './routes/settings.conne · 1 unmodified line
2425 import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
2526 import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance'
2627 import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback'
28+import { Route as SettingsProjectsProjectKeyRouteImport } from './routes/settings.projects_.$projectKey'
2729 import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
2830 import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
2931
@@ -66,6 +68,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ · 36 unmodified lines
6668 path: '/providers',
6769 getParentRoute: () => SettingsRoute,
6870 } as any)
71+const SettingsProjectsRoute = SettingsProjectsRouteImport.update({
72+ id: '/projects',
73+ path: '/projects',
74+ getParentRoute: () => SettingsRoute,
75+} as any)
6976 const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({
7077 id: '/keybindings',
7178 path: '/keybindings',
@@ -101,6 +108,12 @@ const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ · 29 unmodified lines
101108 path: '/connect/callback',
102109 getParentRoute: () => rootRouteImport,
103110 } as any)
111+const SettingsProjectsProjectKeyRoute =
112+ SettingsProjectsProjectKeyRouteImport.update({
113+ id: '/projects_/$projectKey',
114+ path: '/projects/$projectKey',
115+ getParentRoute: () => SettingsRoute,
116+ } as any)
104117 const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({
105118 id: '/draft/$draftId',
106119 path: '/draft/$draftId',
@@ -126,10 +139,12 @@ export interface FileRoutesByFullPath { · 19 unmodified lines
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 }
134149 export interface FileRoutesByTo {
135150 '/connect': typeof ConnectRoute
@@ -143,11 +158,13 @@ export interface FileRoutesByTo { · 7 unmodified lines
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 }
152169 export interface FileRoutesById {
153170 __root__: typeof rootRouteImport
@@ -163,11 +180,13 @@ export interface FileRoutesById { · 9 unmodified lines
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 }
172191 export interface FileRouteTypes {
173192 fileRoutesByFullPath: FileRoutesByFullPath
@@ -184,10 +203,12 @@ export interface FileRouteTypes { · 10 unmodified lines
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 { · 7 unmodified lines
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 { · 8 unmodified lines
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 }
230255 export interface RootRouteChildren {
@@ -294,6 +319,13 @@ declare module '@tanstack/react-router' { · 63 unmodified lines
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' { · 43 unmodified lines
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 { · 32 unmodified lines
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
388429 const SettingsRouteChildren: SettingsRouteChildren = {
@@ -392,8 +433,10 @@ const SettingsRouteChildren: SettingsRouteChildren = { · 3 unmodified lines
392433 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,
393434 SettingsGeneralRoute: SettingsGeneralRoute,
394435 SettingsKeybindingsRoute: SettingsKeybindingsRoute,
436+ SettingsProjectsRoute: SettingsProjectsRoute,
395437 SettingsProvidersRoute: SettingsProvidersRoute,
396438 SettingsSourceControlRoute: SettingsSourceControlRoute,
439+ SettingsProjectsProjectKeyRoute: SettingsProjectsProjectKeyRoute,
397440 }
398441
399442 const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
apps/web/src/routes/settings.projects.tsx+11−0
@@ -0,0 +1,11 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+
3+import { ProjectSettingsPanel } from "../components/settings/ProjectSettingsPanel";
4+
5+function SettingsProjectsRoute() {
6+ return <ProjectSettingsPanel selectedProjectKey={null} />;
7+}
8+
9+export const Route = createFileRoute("/settings/projects")({
10+ component: SettingsProjectsRoute,
11+});
apps/web/src/routes/settings.projects_.$projectKey.tsx+12−0
@@ -0,0 +1,12 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+
3+import { ProjectSettingsPanel } from "../components/settings/ProjectSettingsPanel";
4+
5+function SettingsProjectDetailRoute() {
6+ const { projectKey } = Route.useParams();
7+ return <ProjectSettingsPanel selectedProjectKey={projectKey} />;
8+}
9+
10+export const Route = createFileRoute("/settings/projects_/$projectKey")({
11+ component: SettingsProjectDetailRoute,
12+});