1import {
2 AuthOrchestrationOperateScope,
3 AuthSettingsWriteScope,
4 type EnvironmentId,
5 type ProjectScript,
6 type ProjectScriptIcon,
7 type ResolvedKeybindingsConfig,
8} from "@t3tools/contracts";
9import {
10 isAtomCommandInterrupted,
11 squashAtomCommandFailure,
12 type AtomCommandResult,
13} from "@t3tools/client-runtime/state/runtime";
14import {
15 BugIcon,
16 FlaskConicalIcon,
17 HammerIcon,
18 ListChecksIcon,
19 PlayIcon,
20 WrenchIcon,
21} from "lucide-react";
22import React, {
23 type FormEvent,
24 type KeyboardEvent,
25 useEffect,
26 useLayoutEffect,
27 useRef,
28 useState,
29} from "react";
30
31import {
32 keybindingValueForCommand,
33 decodeProjectScriptKeybindingRule,
34} from "~/lib/projectScriptKeybindings";
35import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic";
36import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts";
37import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
38import { useComposerMenuState } from "./chat/useComposerMenuState";
39import {
40 AlertDialog,
41 AlertDialogClose,
42 AlertDialogDescription,
43 AlertDialogFooter,
44 AlertDialogHeader,
45 AlertDialogPopup,
46 AlertDialogTitle,
47} from "./ui/alert-dialog";
48import { Button } from "./ui/button";
49import {
50 Dialog,
51 DialogDescription,
52 DialogFooter,
53 DialogHeader,
54 DialogPanel,
55 DialogPopup,
56 DialogTitle,
57} from "./ui/dialog";
58import { Input } from "./ui/input";
59import { Label } from "./ui/label";
60import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
61import { Switch } from "./ui/switch";
62import { Textarea } from "./ui/textarea";
63
64const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [
65 { id: "play", label: "Play" },
66 { id: "test", label: "Test" },
67 { id: "lint", label: "Lint" },
68 { id: "configure", label: "Configure" },
69 { id: "build", label: "Build" },
70 { id: "debug", label: "Debug" },
71];
72
73export function ScriptIcon({
74 icon,
75 className = "size-3.5",
76}: {
77 icon: ProjectScriptIcon;
78 className?: string;
79}) {
80 if (icon === "test") return <FlaskConicalIcon className={className} />;
81 if (icon === "lint") return <ListChecksIcon className={className} />;
82 if (icon === "configure") return <WrenchIcon className={className} />;
83 if (icon === "build") return <HammerIcon className={className} />;
84 if (icon === "debug") return <BugIcon className={className} />;
85 return <PlayIcon className={className} />;
86}
87
88export interface NewProjectScriptInput {
89 name: string;
90 command: string;
91 icon: ProjectScriptIcon;
92 runOnWorktreeCreate: boolean;
93 /** Setup scripts only: hold the agent until the script exits. */
94 waitForSetup: boolean;
95 runOnSettle: boolean;
96 /** Omit to preserve the current shortcut when the form did not edit it. */
97 keybinding?: string | null;
98 /** Optional URL to open in the in-app preview when this script runs. */
99 previewUrl: string | null;
100 /** When true, automatically open the preview panel pointed at `previewUrl`. */
101 autoOpenPreview: boolean;
102}
103
104export type ProjectScriptActionResult = AtomCommandResult<void, unknown>;
105
106export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = {
107 name: "",
108 command: "",
109 icon: "play",
110 runOnWorktreeCreate: false,
111 waitForSetup: false,
112 runOnSettle: false,
113 keybinding: null,
114 previewUrl: null,
115 autoOpenPreview: false,
116};
117
118/** What the editor dialog should open with. `scriptId: null` means "add". */
119export interface ProjectScriptEditorRequest {
120 scriptId: string | null;
121 initial: NewProjectScriptInput;
122 /** Validation error to show immediately (e.g. a failed t3.json import). */
123 error?: string;
124}
125
126export function editorRequestForScript(
127 script: ProjectScript,
128 keybindings: ResolvedKeybindingsConfig,
129): ProjectScriptEditorRequest {
130 return {
131 scriptId: script.id,
132 initial: {
133 name: script.name,
134 command: script.command,
135 icon: script.icon,
136 runOnWorktreeCreate: script.runOnWorktreeCreate,
137 waitForSetup: script.runOnWorktreeCreate && script.async === false,
138 runOnSettle: script.runOnSettle ?? false,
139 keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)),
140 previewUrl: script.previewUrl ?? null,
141 autoOpenPreview: script.autoOpenPreview ?? false,
142 },
143 };
144}
145
146/**
147 * Add/edit dialog for a project script, shared by the chat-header scripts menu
148 * and the project settings page. The parent owns which script (if any) is
149 * being edited via `request`; the dialog owns the form state and validation.
150 */
151export function ProjectScriptEditorDialog({
152 environmentId,
153 editScope = AuthOrchestrationOperateScope,
154 request,
155 scripts,
156 onSubmit,
157 onDelete,
158 onClose,
159}: {
160 environmentId: EnvironmentId;
161 editScope?: typeof AuthOrchestrationOperateScope | typeof AuthSettingsWriteScope;
162 request: ProjectScriptEditorRequest | null;
163 /** Existing scripts, used to derive a unique id for new scripts. */
164 scripts: ReadonlyArray<ProjectScript>;
165 onSubmit: (
166 scriptId: string | null,
167 input: NewProjectScriptInput,
168 ) => Promise<ProjectScriptActionResult>;
169 onDelete: (scriptId: string) => void;
170 onClose: () => void;
171}) {
172 const canEditActions = useEnvironmentScope(environmentId, editScope);
173 const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope);
174 const formId = React.useId();
175 const [name, setName] = useState("");
176 const [command, setCommand] = useState("");
177 const [icon, setIcon] = useState<ProjectScriptIcon>("play");
178 const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditActions);
179 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
180 const [waitForSetup, setWaitForSetup] = useState(false);
181 const [runOnSettle, setRunOnSettle] = useState(false);
182 const [keybinding, setKeybinding] = useState("");
183 const [previewUrl, setPreviewUrl] = useState("");
184 const [autoOpenPreview, setAutoOpenPreview] = useState(false);
185 const [validationError, setValidationError] = useState<string | null>(null);
186 const [deleteConfirmOpen, setDeleteConfirmOpen] = useComposerMenuState(!canEditActions);
187 const [savingRequest, setSavingRequest] = useState<ProjectScriptEditorRequest | null>(null);
188 const pendingSubmissionRef = useRef<{ request: ProjectScriptEditorRequest } | null>(null);
189
190 const isOpen = request !== null;
191 const isEditing = request?.scriptId != null;
192 const isSaving = request !== null && savingRequest === request;
193
194 // A save completion must not affect a replacement request or an unmounted editor.
195 useLayoutEffect(
196 () => () => {
197 if (pendingSubmissionRef.current?.request === request) {
198 pendingSubmissionRef.current = null;
199 }
200 },
201 [request],
202 );
203
204 // Hydrate the form whenever a new request opens the dialog.
205 useEffect(() => {
206 if (!request) return;
207 setName(request.initial.name);
208 setCommand(request.initial.command);
209 setIcon(request.initial.icon);
210 setIconPickerOpen(false);
211 setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate);
212 setWaitForSetup(request.initial.waitForSetup);
213 setRunOnSettle(request.initial.runOnSettle);
214 setKeybinding(request.initial.keybinding ?? "");
215 setPreviewUrl(request.initial.previewUrl ?? "");
216 setAutoOpenPreview(request.initial.autoOpenPreview);
217 setValidationError(request.error ?? null);
218 setSavingRequest(null);
219 }, [request, setIconPickerOpen]);
220
221 const close = () => {
222 pendingSubmissionRef.current = null;
223 setSavingRequest(null);
224 setIconPickerOpen(false);
225 onClose();
226 };
227
228 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {
229 if (!readEnvironmentScope(environmentId, editScope)) return;
230 if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) return;
231 if (event.key === "Tab") return;
232 event.preventDefault();
233 if (event.key === "Backspace" || event.key === "Delete") {
234 setKeybinding("");
235 return;
236 }
237 const next = keybindingFromKeyboardEvent(event, navigator.platform);
238 if (!next) return;
239 setKeybinding(next);
240 };
241
242 const submit = async (event: FormEvent) => {
243 event.preventDefault();
244 if (!request || pendingSubmissionRef.current !== null) return;
245 if (!readEnvironmentScope(environmentId, editScope)) {
246 setValidationError("This connection cannot change project actions.");
247 return;
248 }
249 const changesKeybinding =
250 (keybinding.trim() || null) !== (request.initial.keybinding?.trim() || null);
251 const canChangeKeybinding = readEnvironmentScope(environmentId, AuthSettingsWriteScope);
252 if (changesKeybinding && !canChangeKeybinding) {
253 setValidationError("This connection cannot change keyboard shortcuts.");
254 return;
255 }
256 const trimmedName = name.trim();
257 const trimmedCommand = command.trim();
258 if (trimmedName.length === 0) {
259 setValidationError("Name is required.");
260 return;
261 }
262 if (trimmedCommand.length === 0) {
263 setValidationError("Command is required.");
264 return;
265 }
266
267 setValidationError(null);
268 let payload: NewProjectScriptInput;
269 try {
270 const scriptIdForValidation =
271 request.scriptId ??
272 nextProjectScriptId(
273 trimmedName,
274 scripts.map((script) => script.id),
275 );
276 const keybindingRule = decodeProjectScriptKeybindingRule({
277 keybinding,
278 command: commandForProjectScript(scriptIdForValidation),
279 });
280 const trimmedPreviewUrl = previewUrl.trim();
281 payload = {
282 name: trimmedName,
283 command: trimmedCommand,
284 icon,
285 runOnWorktreeCreate,
286 waitForSetup: runOnWorktreeCreate && waitForSetup,
287 runOnSettle,
288 ...((request.scriptId === null && canChangeKeybinding) || changesKeybinding
289 ? { keybinding: keybindingRule?.key ?? null }
290 : {}),
291 previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null,
292 autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false,
293 } satisfies NewProjectScriptInput;
294 } catch (error) {
295 setValidationError(error instanceof Error ? error.message : "Failed to save action.");
296 return;
297 }
298
299 const submission = { request };
300 pendingSubmissionRef.current = submission;
301 setSavingRequest(request);
302 setIconPickerOpen(false);
303 try {
304 const result = await onSubmit(request.scriptId, payload);
305 if (pendingSubmissionRef.current === submission) {
306 if (result._tag === "Failure") {
307 if (!isAtomCommandInterrupted(result)) {
308 const error = squashAtomCommandFailure(result);
309 setValidationError(error instanceof Error ? error.message : "Failed to save action.");
310 }
311 } else {
312 close();
313 }
314 }
315 } catch (error) {
316 if (pendingSubmissionRef.current === submission) {
317 setValidationError(error instanceof Error ? error.message : "Failed to save action.");
318 }
319 }
320 if (pendingSubmissionRef.current === submission) {
321 pendingSubmissionRef.current = null;
322 setSavingRequest(null);
323 }
324 };
325
326 return (
327 <>
328 <Dialog
329 open={isOpen}
330 onOpenChange={(open) => {
331 if (!open) {
332 close();
333 }
334 }}
335 >
336 <DialogPopup>
337 <DialogHeader>
338 <DialogTitle>{isEditing ? "Edit Action" : "Add Action"}</DialogTitle>
339 <DialogDescription>
340 Actions are project-scoped commands you can run from the top bar or keybindings.
341 </DialogDescription>
342 </DialogHeader>
343 <DialogPanel>
344 <form id={formId} onSubmit={submit}>
345 <fieldset className="space-y-4" disabled={isSaving || !canEditActions}>
346 <div className="space-y-1.5">
347 <Label htmlFor="script-name">Name</Label>
348 <div className="flex items-center gap-2">
349 <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}>
350 <PopoverTrigger
351 render={
352 <Button
353 type="button"
354 variant="outline"
355 className="size-9 shrink-0"
356 aria-label="Choose icon"
357 />
358 }
359 >
360 <ScriptIcon icon={icon} className="size-4.5" />
361 </PopoverTrigger>
362 <PopoverPopup align="start">
363 <div className="grid grid-cols-3 gap-2">
364 {SCRIPT_ICONS.map((entry) => {
365 const isSelected = entry.id === icon;
366 return (
367 <button
368 key={entry.id}
369 type="button"
370 className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${
371 isSelected
372 ? "border-primary/70 bg-primary/10 ring-inset dark:ring-1 dark:ring-primary/30"
373 : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]"
374 }`}
375 onClick={() => {
376 setIcon(entry.id);
377 setIconPickerOpen(false);
378 }}
379 >
380 <ScriptIcon icon={entry.id} className="size-4" />
381 <span>{entry.label}</span>
382 </button>
383 );
384 })}
385 </div>
386 </PopoverPopup>
387 </Popover>
388 <Input
389 id="script-name"
390 autoFocus
391 placeholder="Test"
392 value={name}
393 onChange={(event) => setName(event.target.value)}
394 />
395 </div>
396 </div>
397 <div className="space-y-1.5">
398 <Label htmlFor="script-keybinding">Keybinding</Label>
399 <Input
400 disabled={!canWriteSettings}
401 id="script-keybinding"
402 placeholder="Press shortcut"
403 value={keybinding}
404 readOnly
405 onKeyDown={captureKeybinding}
406 />
407 <p className="text-xs text-muted-foreground">
408 Press a shortcut. Use <code>Backspace</code> to clear. Shortcuts are
409 environment-wide. Projects using the same action share its shortcut.
410 </p>
411 </div>
412 <div className="space-y-1.5">
413 <Label htmlFor="script-command">Command</Label>
414 <Textarea
415 id="script-command"
416 placeholder="bun test"
417 value={command}
418 onChange={(event) => setCommand(event.target.value)}
419 />
420 </div>
421 <div className="space-y-1.5">
422 <Label htmlFor="script-preview-url">Preview URL (optional)</Label>
423 <Input
424 id="script-preview-url"
425 placeholder="http://localhost:5173"
426 value={previewUrl}
427 onChange={(event) => setPreviewUrl(event.target.value)}
428 />
429 <p className="text-xs text-muted-foreground">
430 Open this URL in the in-app preview when this action runs.
431 </p>
432 </div>
433 <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]">
434 <span>Run automatically on worktree creation</span>
435 <Switch
436 checked={runOnWorktreeCreate}
437 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
438 />
439 </label>
440 <label
441 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] ${
442 runOnWorktreeCreate ? "" : "opacity-60"
443 }`}
444 >
445 <span>Wait for it to finish before the agent starts</span>
446 <Switch
447 checked={waitForSetup}
448 disabled={!runOnWorktreeCreate}
449 onCheckedChange={(checked) => setWaitForSetup(Boolean(checked))}
450 />
451 </label>
452 <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]">
453 <span>Run in the thread's worktree when the thread settles</span>
454 <Switch
455 checked={runOnSettle}
456 onCheckedChange={(checked) => setRunOnSettle(Boolean(checked))}
457 />
458 </label>
459 <label
460 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] ${
461 previewUrl.trim().length === 0 ? "opacity-60" : ""
462 }`}
463 >
464 <span>Open preview automatically when this action runs</span>
465 <Switch
466 checked={autoOpenPreview}
467 disabled={previewUrl.trim().length === 0}
468 onCheckedChange={(checked) => setAutoOpenPreview(Boolean(checked))}
469 />
470 </label>
471 {validationError && <p className="text-sm text-destructive">{validationError}</p>}
472 </fieldset>
473 </form>
474 </DialogPanel>
475 <DialogFooter variant="bare">
476 {isEditing && (
477 <Button
478 type="button"
479 variant="destructive-outline"
480 className="mr-auto"
481 disabled={isSaving || !canEditActions}
482 onClick={() => setDeleteConfirmOpen(true)}
483 >
484 Delete
485 </Button>
486 )}
487 <Button type="button" variant="outline" onClick={close}>
488 Cancel
489 </Button>
490 <Button form={formId} type="submit" disabled={isSaving || !canEditActions}>
491 {isSaving ? "Saving…" : isEditing ? "Save changes" : "Save action"}
492 </Button>
493 </DialogFooter>
494 </DialogPopup>
495 </Dialog>
496
497 <AlertDialog open={deleteConfirmOpen && canEditActions} onOpenChange={setDeleteConfirmOpen}>
498 <AlertDialogPopup>
499 <AlertDialogHeader>
500 <AlertDialogTitle>Delete action "{name}"?</AlertDialogTitle>
501 <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
502 </AlertDialogHeader>
503 <AlertDialogFooter>
504 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
505 <Button
506 variant="destructive"
507 disabled={isSaving || !canEditActions}
508 onClick={() => {
509 if (!request?.scriptId) return;
510 if (!readEnvironmentScope(environmentId, editScope)) {
511 setValidationError("This connection cannot change project actions.");
512 return;
513 }
514 setDeleteConfirmOpen(false);
515 close();
516 onDelete(request.scriptId);
517 }}
518 >
519 Delete action
520 </Button>
521 </AlertDialogFooter>
522 </AlertDialogPopup>
523 </AlertDialog>
524 </>
525 );
526}