1import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";
2import {
3 AuthSettingsWriteScope,
4 type EnvironmentId,
5 type ProjectScript,
6 type T3ProjectFileScript,
7 type ResolvedKeybindingsConfig,
8} from "@t3tools/contracts";
9import { useAtomValue } from "@effect/atom-react";
10import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
11import {
12 isAtomCommandInterrupted,
13 squashAtomCommandFailure,
14} from "@t3tools/client-runtime/state/runtime";
15import { projectScriptMenuLabel } from "@t3tools/shared/projectScripts";
16import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon, WrenchIcon } from "lucide-react";
17import React, { useCallback, useMemo, useState } from "react";
18
19import { commandForProjectScript, primaryProjectScript } from "~/projectScripts";
20import { shortcutLabelForCommand } from "~/keybindings";
21import { serverEnvironment } from "~/state/server";
22import { readEnvironmentScope } from "~/state/session";
23import {
24 EMPTY_PROJECT_SCRIPT_INPUT,
25 editorRequestForScript,
26 ProjectScriptEditorDialog,
27 ScriptIcon,
28 type NewProjectScriptInput,
29 type ProjectScriptActionResult,
30 type ProjectScriptEditorRequest,
31} from "./projectScriptEditor";
32import { Button } from "./ui/button";
33import { Group, GroupSeparator } from "./ui/group";
34import {
35 Menu,
36 MenuGroup,
37 MenuGroupLabel,
38 MenuItem,
39 MenuItemLabel,
40 MenuPopup,
41 MenuSeparator,
42 MenuShortcut,
43 MenuTrigger,
44 MenuSub,
45 MenuSubTrigger,
46 MenuSubPopup,
47} from "./ui/menu";
48import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
49import { cn } from "~/lib/utils";
50import {
51 THREAD_DETAILS_PANEL_CHEVRON_CLASS,
52 THREAD_DETAILS_PANEL_ICON_CLASS,
53 THREAD_DETAILS_PANEL_LABEL_CLASS,
54 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
55 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
56} from "./chat/threadDetailsPanelStyles";
57
58export type { NewProjectScriptInput, ProjectScriptActionResult };
59
60const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];
61
62interface ProjectScriptsControlProps {
63 displayMode?: "toolbar" | "panel";
64 presentation?: "toolbar" | "menu";
65 onRequestMenuClose?: () => void;
66 environmentId: EnvironmentId;
67 scripts: ReadonlyArray<ProjectScript>;
68 /** Scripts declared in the project's checked-in t3.json, offered for import. */
69 fileScripts?: ReadonlyArray<T3ProjectFileScript>;
70 preferredScriptId?: string | null;
71 onRunScript?: ((script: ProjectScript) => void) | undefined;
72 onAddScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>;
73 onUpdateScript: (
74 scriptId: string,
75 input: NewProjectScriptInput,
76 ) => Promise<ProjectScriptActionResult>;
77 onDeleteScript: (scriptId: string) => Promise<ProjectScriptActionResult>;
78}
79
80export default function ProjectScriptsControl({
81 displayMode = "toolbar",
82 presentation = "toolbar",
83 onRequestMenuClose,
84 environmentId,
85 scripts,
86 fileScripts = NO_FILE_SCRIPTS,
87 preferredScriptId = null,
88 onRunScript,
89 onAddScript,
90 onUpdateScript,
91 onDeleteScript,
92}: ProjectScriptsControlProps) {
93 const isPanel = displayMode === "panel";
94 const ActionGroup = isPanel ? "div" : Group;
95 const panelAnchorRef = React.useRef<HTMLDivElement | null>(null);
96 const keybindings =
97 useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ??
98 DEFAULT_RESOLVED_KEYBINDINGS;
99 const [actionsMenuOpen, setActionsMenuOpen] = useState({
100 presentation,
101 scripts: false,
102 imports: false,
103 });
104 if (actionsMenuOpen.presentation !== presentation) {
105 setActionsMenuOpen({ presentation, scripts: false, imports: false });
106 }
107 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
108
109 const primaryScript = useMemo(() => {
110 if (preferredScriptId) {
111 const preferred = scripts.find((script) => script.id === preferredScriptId);
112 if (preferred) return preferred;
113 }
114 return primaryProjectScript(scripts);
115 }, [preferredScriptId, scripts]);
116 const importableScripts = useMemo(
117 () =>
118 fileScripts.filter(
119 (fileScript) =>
120 !scripts.some(
121 (script) =>
122 script.command === fileScript.command ||
123 script.name.toLowerCase() === fileScript.name.toLowerCase(),
124 ),
125 ),
126 [fileScripts, scripts],
127 );
128
129 const openAddDialog = () => {
130 setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT });
131 };
132
133 const openEditDialog = (script: ProjectScript) => {
134 onRequestMenuClose?.();
135 setActionsMenuOpen({ presentation, scripts: false, imports: false });
136 setEditorRequest(editorRequestForScript(script, keybindings));
137 };
138
139 const submitScript = useCallback(
140 (scriptId: string | null, input: NewProjectScriptInput) =>
141 scriptId === null ? onAddScript(input) : onUpdateScript(scriptId, input),
142 [onAddScript, onUpdateScript],
143 );
144
145 const importFileScript = async (fileScript: T3ProjectFileScript) => {
146 const payload: NewProjectScriptInput = {
147 name: fileScript.name,
148 command: fileScript.command,
149 icon: fileScript.icon ?? "play",
150 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
151 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,
152 runOnSettle: fileScript.runOnSettle ?? false,
153 ...(readEnvironmentScope(environmentId, AuthSettingsWriteScope) ? { keybinding: null } : {}),
154 previewUrl: fileScript.previewUrl ?? null,
155 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
156 };
157 const result = await onAddScript(payload);
158 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
159 // Surface the failure through the regular add dialog, prefilled so the
160 // user can adjust and retry.
161 const error = squashAtomCommandFailure(result);
162 setEditorRequest({
163 scriptId: null,
164 initial: payload,
165 error: error instanceof Error ? error.message : "Failed to import action.",
166 });
167 }
168 };
169
170 const importMenuItems = importableScripts.length > 0 && (
171 <>
172 {scripts.length > 0 && <MenuSeparator />}
173 <MenuGroup>
174 <MenuGroupLabel>From t3.json</MenuGroupLabel>
175 {importableScripts.map((fileScript) => (
176 <MenuItem
177 density={presentation === "menu" ? "touch" : "default"}
178 key={`${fileScript.name} ${fileScript.command}`}
179 onClick={() => void importFileScript(fileScript)}
180 >
181 <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4" />
182 <MenuItemLabel>{fileScript.name}</MenuItemLabel>
183 <MenuShortcut>
184 <DownloadIcon className="size-3.5" aria-label="Import" />
185 </MenuShortcut>
186 </MenuItem>
187 ))}
188 </MenuGroup>
189 </>
190 );
191
192 const scriptItems = (
193 <>
194 {scripts.map((script) => {
195 const shortcutLabel = shortcutLabelForCommand(
196 keybindings,
197 commandForProjectScript(script.id),
198 );
199 return (
200 <MenuItem
201 density={presentation === "menu" ? "touch" : "default"}
202 key={script.id}
203 className="group"
204 disabled={!onRunScript}
205 onClick={() => onRunScript?.(script)}
206 >
207 <ScriptIcon icon={script.icon} className="size-4" />
208 <MenuItemLabel>{projectScriptMenuLabel(script)}</MenuItemLabel>
209 <span className="relative ms-auto flex h-6 min-w-6 items-center justify-end">
210 {shortcutLabel &&
211 (presentation === "menu" ? (
212 <MenuShortcut className="ms-0 mr-7">{shortcutLabel}</MenuShortcut>
213 ) : (
214 // The shortcut yields its slot to the edit button on hover.
215 <span className="transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0">
216 <MenuShortcut className="ms-0">{shortcutLabel}</MenuShortcut>
217 </span>
218 ))}
219 <span
220 className={`absolute right-0 top-1/2 flex -translate-y-1/2 ${presentation === "menu" ? "" : "opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-visible:opacity-100 group-focus-visible:pointer-events-auto"}`}
221 >
222 <Button
223 type="button"
224 variant="ghost"
225 size="icon-xs"
226 className="size-6"
227 aria-label={`Edit ${script.name}`}
228 onPointerDown={(event) => {
229 event.preventDefault();
230 event.stopPropagation();
231 }}
232 onClick={(event) => {
233 event.preventDefault();
234 event.stopPropagation();
235 openEditDialog(script);
236 }}
237 >
238 <SettingsIcon className="size-3.5" />
239 </Button>
240 </span>
241 </span>
242 </MenuItem>
243 );
244 })}
245 {importMenuItems}
246 <MenuItem density={presentation === "menu" ? "touch" : "default"} onClick={openAddDialog}>
247 <PlusIcon className="size-4" />
248 <MenuItemLabel>{isPanel ? "Add project script" : "Add action"}</MenuItemLabel>
249 </MenuItem>
250 </>
251 );
252
253 return (
254 <>
255 {presentation === "menu" ? (
256 <>
257 {primaryScript && (
258 <MenuItem
259 density={presentation === "menu" ? "touch" : "default"}
260 disabled={!onRunScript}
261 onClick={() => onRunScript?.(primaryScript)}
262 >
263 <ScriptIcon icon={primaryScript.icon} className="size-4" />
264 <MenuItemLabel>Run {primaryScript.name}</MenuItemLabel>
265 <MenuShortcut>
266 {shortcutLabelForCommand(keybindings, commandForProjectScript(primaryScript.id))}
267 </MenuShortcut>
268 </MenuItem>
269 )}
270 {scripts.length > 0 || importableScripts.length > 0 ? (
271 <MenuSub
272 open={actionsMenuOpen.scripts}
273 onOpenChange={(open) =>
274 setActionsMenuOpen({ presentation, scripts: open, imports: false })
275 }
276 >
277 <MenuSubTrigger density="touch">
278 <ScriptIcon icon="play" className="size-4" />
279 <MenuItemLabel>Project actions</MenuItemLabel>
280 </MenuSubTrigger>
281 <MenuSubPopup>{scriptItems}</MenuSubPopup>
282 </MenuSub>
283 ) : (
284 <MenuItem
285 density={presentation === "menu" ? "touch" : "default"}
286 onClick={openAddDialog}
287 >
288 <PlusIcon className="size-4" />
289 <MenuItemLabel>Add project action…</MenuItemLabel>
290 </MenuItem>
291 )}
292 </>
293 ) : primaryScript ? (
294 <ActionGroup
295 role="group"
296 aria-label="Project scripts"
297 {...(isPanel
298 ? { className: THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, ref: panelAnchorRef }
299 : {})}
300 >
301 <Tooltip>
302 <TooltipTrigger
303 render={
304 <ThreadDetailsControl
305 size="xs"
306 variant={isPanel ? "ghost" : "outline"}
307 part="primary"
308 panel={isPanel}
309 className={isPanel ? undefined : "w-7 sm:w-6 @3xl/header-actions:w-auto!"}
310 aria-label={`Run ${primaryScript.name}`}
311 // The tooltip wrapper replaces data-slot="button", so themed
312 // toolbar styling needs its own hook.
313 data-toolbar-control=""
314 aria-disabled={!onRunScript || undefined}
315 onClick={() => onRunScript?.(primaryScript)}
316 />
317 }
318 >
319 <ScriptIcon
320 icon={primaryScript.icon}
321 {...(isPanel ? { className: THREAD_DETAILS_PANEL_ICON_CLASS } : {})}
322 />
323 <span
324 className={cn(
325 "sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5",
326 isPanel && cn("not-sr-only ml-0 truncate", THREAD_DETAILS_PANEL_LABEL_CLASS),
327 )}
328 >
329 {primaryScript.name}
330 </span>
331 </TooltipTrigger>
332 <TooltipPopup side="top">
333 {onRunScript
334 ? `Run ${primaryScript.name}`
335 : "Pair this client again with permission to run terminal commands."}
336 </TooltipPopup>
337 </Tooltip>
338 {isPanel ? (
339 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
340 ) : (
341 <GroupSeparator className="hidden @3xl/header-actions:block" />
342 )}
343 <Menu
344 open={actionsMenuOpen.scripts}
345 onOpenChange={(open) =>
346 setActionsMenuOpen({ presentation, scripts: open, imports: false })
347 }
348 >
349 <MenuTrigger
350 render={
351 <ThreadDetailsControl
352 size={isPanel ? "sm" : "icon-xs"}
353 variant={isPanel ? "ghost" : "outline"}
354 part="secondary"
355 panel={isPanel}
356 aria-label="Script actions"
357 />
358 }
359 >
360 <ChevronDownIcon
361 className={isPanel ? THREAD_DETAILS_PANEL_CHEVRON_CLASS : "size-4"}
362 />
363 </MenuTrigger>
364 <MenuPopup
365 align="end"
366 {...(isPanel ? { anchor: panelAnchorRef } : {})}
367 className={isPanel ? "w-(--anchor-width)" : undefined}
368 >
369 {scriptItems}
370 </MenuPopup>
371 </Menu>
372 </ActionGroup>
373 ) : scripts.length > 0 || importableScripts.length > 0 ? (
374 // No one-click action (only a settle action, or only t3.json imports),
375 // so the saved actions stay reachable through this menu.
376 isPanel ? (
377 <div
378 role="group"
379 aria-label="Project actions"
380 className={THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS}
381 ref={panelAnchorRef}
382 >
383 <ThreadDetailsControl
384 size="sm"
385 variant="ghost"
386 part="primary"
387 aria-label="Project actions"
388 onClick={() => setActionsMenuOpen({ presentation, scripts: false, imports: true })}
389 >
390 <WrenchIcon className={THREAD_DETAILS_PANEL_ICON_CLASS} />
391 <span className={cn("min-w-0 truncate", THREAD_DETAILS_PANEL_LABEL_CLASS)}>
392 Actions
393 </span>
394 </ThreadDetailsControl>
395 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
396 <Menu
397 highlightItemOnHover={false}
398 open={actionsMenuOpen.imports}
399 onOpenChange={(open) =>
400 setActionsMenuOpen({ presentation, scripts: false, imports: open })
401 }
402 >
403 <MenuTrigger
404 render={
405 <ThreadDetailsControl
406 size="sm"
407 variant="ghost"
408 part="secondary"
409 aria-label="Choose project action"
410 />
411 }
412 >
413 <ChevronDownIcon className={THREAD_DETAILS_PANEL_CHEVRON_CLASS} />
414 </MenuTrigger>
415 <MenuPopup align="end" anchor={panelAnchorRef} className="w-(--anchor-width)">
416 {scriptItems}
417 </MenuPopup>
418 </Menu>
419 </div>
420 ) : (
421 <Menu
422 highlightItemOnHover={false}
423 open={actionsMenuOpen.imports}
424 onOpenChange={(open) =>
425 setActionsMenuOpen({ presentation, scripts: false, imports: open })
426 }
427 >
428 <MenuTrigger
429 render={<Button size="xs" variant="outline" aria-label="Project actions" />}
430 >
431 <WrenchIcon className="size-3.5" />
432 <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5">
433 Actions
434 </span>
435 <ChevronDownIcon className="size-3.5" />
436 </MenuTrigger>
437 <MenuPopup align="end">{scriptItems}</MenuPopup>
438 </Menu>
439 )
440 ) : (
441 <Tooltip>
442 <TooltipTrigger
443 render={
444 <ThreadDetailsControl
445 size="xs"
446 variant={isPanel ? "ghost" : "outline"}
447 part="row"
448 panel={isPanel}
449 className={isPanel ? undefined : "w-7 sm:w-6 @3xl/header-actions:w-auto!"}
450 aria-label={isPanel ? "Add project script" : "Add action"}
451 // The tooltip wrapper replaces data-slot="button", so themed
452 // toolbar styling needs its own hook.
453 data-toolbar-control=""
454 onClick={openAddDialog}
455 />
456 }
457 >
458 <PlusIcon className="size-3.5" />
459 <span
460 className={cn(
461 "sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5",
462 isPanel && cn("not-sr-only ml-0.5", THREAD_DETAILS_PANEL_LABEL_CLASS),
463 )}
464 >
465 {isPanel ? "Add project script" : "Add action"}
466 </span>
467 </TooltipTrigger>
468 <TooltipPopup side="top">{isPanel ? "Add project script" : "Add action"}</TooltipPopup>
469 </Tooltip>
470 )}
471
472 <ProjectScriptEditorDialog
473 environmentId={environmentId}
474 request={editorRequest}
475 scripts={scripts}
476 onSubmit={submitScript}
477 onDelete={(scriptId) => void onDeleteScript(scriptId)}
478 onClose={() => setEditorRequest(null)}
479 />
480 </>
481 );
482}