| 1 | import { |
| 2 | AuthOrchestrationOperateScope, |
| 3 | AuthSettingsWriteScope, |
| 4 | type EnvironmentId, |
| 5 | type ProjectScript, |
| 6 | type ProjectScriptIcon, |
| 7 | type ResolvedKeybindingsConfig, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import { |
| 10 | isAtomCommandInterrupted, |
| 11 | squashAtomCommandFailure, |
| 12 | type AtomCommandResult, |
| 13 | } from "@t3tools/client-runtime/state/runtime"; |
| 14 | import { |
| 15 | BugIcon, |
| 16 | FlaskConicalIcon, |
| 17 | HammerIcon, |
| 18 | ListChecksIcon, |
| 19 | PlayIcon, |
| 20 | WrenchIcon, |
| 21 | } from "lucide-react"; |
| 22 | import React, { |
| 23 | type FormEvent, |
| 24 | type KeyboardEvent, |
| 25 | useEffect, |
| 26 | useLayoutEffect, |
| 27 | useRef, |
| 28 | useState, |
| 29 | } from "react"; |
| 30 | |
| 31 | import { |
| 32 | keybindingValueForCommand, |
| 33 | decodeProjectScriptKeybindingRule, |
| 34 | } from "~/lib/projectScriptKeybindings"; |
| 35 | import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; |
| 36 | import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts"; |
| 37 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; |
| 38 | import { useComposerMenuState } from "./chat/useComposerMenuState"; |
| 39 | import { |
| 40 | AlertDialog, |
| 41 | AlertDialogClose, |
| 42 | AlertDialogDescription, |
| 43 | AlertDialogFooter, |
| 44 | AlertDialogHeader, |
| 45 | AlertDialogPopup, |
| 46 | AlertDialogTitle, |
| 47 | } from "./ui/alert-dialog"; |
| 48 | import { Button } from "./ui/button"; |
| 49 | import { |
| 50 | Dialog, |
| 51 | DialogDescription, |
| 52 | DialogFooter, |
| 53 | DialogHeader, |
| 54 | DialogPanel, |
| 55 | DialogPopup, |
| 56 | DialogTitle, |
| 57 | } from "./ui/dialog"; |
| 58 | import { Input } from "./ui/input"; |
| 59 | import { Label } from "./ui/label"; |
| 60 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; |
| 61 | import { Switch } from "./ui/switch"; |
| 62 | import { Textarea } from "./ui/textarea"; |
| 63 | |
| 64 | const 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 | |
| 73 | export 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 | |
| 88 | export 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 | |
| 104 | export type ProjectScriptActionResult = AtomCommandResult<void, unknown>; |
| 105 | |
| 106 | export 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". */ |
| 119 | export 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 | |
| 126 | export 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 | */ |
| 151 | export 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 | } |