| author | |
| committer | |
| log | 288d8e3457f0466da2cbef2eab648331c969b8a7 |
| tree | 8291864bac7c791f5728a4fa3e46a5260d208884 |
| parent | 5208bdeb0db95063091a29f97af3548436c5f291 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>11 files changed, 1609 insertions(+), 756 deletions(-)
apps/web/src/components/CommandPalette.tsx+11| ... | @@ -1505,6 +1505,17 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1505,6 +1505,17 @@ function OpenCommandPaletteDialog(props: { |
| 1505 | }, | 1505 | }, |
| 1506 | }); | 1506 | }); |
| 1507 | 1507 | ||
| 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 | |||
| 1508 | const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); | 1519 | const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); |
| 1509 | const sourceSelectionViewValue = | 1520 | const sourceSelectionViewValue = |
| 1510 | addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; | 1521 | addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; |
apps/web/src/components/ProjectScriptsControl.tsx+32-382| ... | @@ -1,61 +1,28 @@ | ... | @@ -1,61 +1,28 @@ |
| 1 | import type { | 1 | import type { |
| 2 | ProjectScript, | 2 | ProjectScript, |
| 3 | ProjectScriptIcon, | ||
| 4 | ResolvedKeybindingsConfig, | 3 | ResolvedKeybindingsConfig, |
| 5 | T3ProjectFileScript, | 4 | T3ProjectFileScript, |
| 6 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 7 | import { | 6 | import { |
| 8 | isAtomCommandInterrupted, | 7 | isAtomCommandInterrupted, |
| 9 | squashAtomCommandFailure, | 8 | squashAtomCommandFailure, |
| 10 | type AtomCommandResult, | ||
| 11 | } from "@t3tools/client-runtime/state/runtime"; | 9 | } from "@t3tools/client-runtime/state/runtime"; |
| 12 | import { | 10 | import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon } from "lucide-react"; |
| 13 | BugIcon, | 11 | import { useCallback, useMemo, useState } from "react"; |
| 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"; | ||
| 25 | 12 | ||
| 26 | import { | 13 | import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; |
| 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"; | ||
| 36 | import { shortcutLabelForCommand } from "~/keybindings"; | 14 | import { shortcutLabelForCommand } from "~/keybindings"; |
| 37 | import { | 15 | import { |
| 38 | AlertDialog, | 16 | EMPTY_PROJECT_SCRIPT_INPUT, |
| 39 | AlertDialogClose, | 17 | editorRequestForScript, |
| 40 | AlertDialogDescription, | 18 | ProjectScriptEditorDialog, |
| 41 | AlertDialogFooter, | 19 | ScriptIcon, |
| 42 | AlertDialogHeader, | 20 | type NewProjectScriptInput, |
| 43 | AlertDialogPopup, | 21 | type ProjectScriptActionResult, |
| 44 | AlertDialogTitle, | 22 | type ProjectScriptEditorRequest, |
| 45 | } from "./ui/alert-dialog"; | 23 | } from "./projectScriptEditor"; |
| 46 | import { Button } from "./ui/button"; | 24 | 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"; | ||
| 56 | import { Group, GroupSeparator } from "./ui/group"; | 25 | import { Group, GroupSeparator } from "./ui/group"; |
| 57 | import { Input } from "./ui/input"; | ||
| 58 | import { Label } from "./ui/label"; | ||
| 59 | import { | 26 | import { |
| 60 | Menu, | 27 | Menu, |
| 61 | MenuGroup, | 28 | MenuGroup, |
| ... | @@ -66,48 +33,9 @@ import { | ... | @@ -66,48 +33,9 @@ import { |
| 66 | MenuShortcut, | 33 | MenuShortcut, |
| 67 | MenuTrigger, | 34 | MenuTrigger, |
| 68 | } from "./ui/menu"; | 35 | } from "./ui/menu"; |
| 69 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; | ||
| 70 | import { Switch } from "./ui/switch"; | ||
| 71 | import { Textarea } from "./ui/textarea"; | ||
| 72 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 36 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 73 | 37 | ||
| 74 | const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ | 38 | export type { NewProjectScriptInput, ProjectScriptActionResult }; |
| 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>; | ||
| 111 | 39 | ||
| 112 | const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = []; | 40 | const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = []; |
| 113 | 41 | ||
| ... | @@ -136,23 +64,11 @@ export default function ProjectScriptsControl({ | ... | @@ -136,23 +64,11 @@ export default function ProjectScriptsControl({ |
| 136 | onUpdateScript, | 64 | onUpdateScript, |
| 137 | onDeleteScript, | 65 | onDeleteScript, |
| 138 | }: ProjectScriptsControlProps) { | 66 | }: ProjectScriptsControlProps) { |
| 139 | const addScriptFormId = React.useId(); | ||
| 140 | const [editingScriptId, setEditingScriptId] = useState<string | null>(null); | ||
| 141 | const [actionsMenuOpen, setActionsMenuOpen] = useState({ | 67 | const [actionsMenuOpen, setActionsMenuOpen] = useState({ |
| 142 | scripts: false, | 68 | scripts: false, |
| 143 | imports: false, | 69 | imports: false, |
| 144 | }); | 70 | }); |
| 145 | const [dialogOpen, setDialogOpen] = useState(false); | 71 | const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null); |
| 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); | ||
| 156 | 72 | ||
| 157 | const primaryScript = useMemo(() => { | 73 | const primaryScript = useMemo(() => { |
| 158 | if (preferredScriptId) { | 74 | if (preferredScriptId) { |
| ... | @@ -173,112 +89,23 @@ export default function ProjectScriptsControl({ | ... | @@ -173,112 +89,23 @@ export default function ProjectScriptsControl({ |
| 173 | ), | 89 | ), |
| 174 | [fileScripts, scripts], | 90 | [fileScripts, scripts], |
| 175 | ); | 91 | ); |
| 176 | const isEditing = editingScriptId !== null; | ||
| 177 | const dropdownItemClassName = | 92 | const dropdownItemClassName = |
| 178 | "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"; | 93 | "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"; |
| 179 | 94 | ||
| 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 | |||
| 247 | const openAddDialog = () => { | 95 | const openAddDialog = () => { |
| 248 | setEditingScriptId(null); | 96 | setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT }); |
| 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); | ||
| 259 | }; | 97 | }; |
| 260 | 98 | ||
| 261 | const openEditDialog = (script: ProjectScript) => { | 99 | const openEditDialog = (script: ProjectScript) => { |
| 262 | setActionsMenuOpen({ scripts: false, imports: false }); | 100 | setActionsMenuOpen({ scripts: false, imports: false }); |
| 263 | setEditingScriptId(script.id); | 101 | setEditorRequest(editorRequestForScript(script, keybindings)); |
| 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); | ||
| 274 | }; | 102 | }; |
| 275 | 103 | ||
| 276 | const confirmDeleteScript = useCallback(() => { | 104 | const submitScript = useCallback( |
| 277 | if (!editingScriptId) return; | 105 | (scriptId: string | null, input: NewProjectScriptInput) => |
| 278 | setDeleteConfirmOpen(false); | 106 | scriptId === null ? onAddScript(input) : onUpdateScript(scriptId, input), |
| 279 | setDialogOpen(false); | 107 | [onAddScript, onUpdateScript], |
| 280 | void onDeleteScript(editingScriptId); | 108 | ); |
| 281 | }, [editingScriptId, onDeleteScript]); | ||
| 282 | 109 | ||
| 283 | const importFileScript = async (fileScript: T3ProjectFileScript) => { | 110 | const importFileScript = async (fileScript: T3ProjectFileScript) => { |
| 284 | const payload: NewProjectScriptInput = { | 111 | const payload: NewProjectScriptInput = { |
| ... | @@ -295,17 +122,11 @@ export default function ProjectScriptsControl({ | ... | @@ -295,17 +122,11 @@ export default function ProjectScriptsControl({ |
| 295 | // Surface the failure through the regular add dialog, prefilled so the | 122 | // Surface the failure through the regular add dialog, prefilled so the |
| 296 | // user can adjust and retry. | 123 | // user can adjust and retry. |
| 297 | const error = squashAtomCommandFailure(result); | 124 | const error = squashAtomCommandFailure(result); |
| 298 | setEditingScriptId(null); | 125 | setEditorRequest({ |
| 299 | setName(payload.name); | 126 | scriptId: null, |
| 300 | setCommand(payload.command); | 127 | initial: payload, |
| 301 | setIcon(payload.icon); | 128 | error: error instanceof Error ? error.message : "Failed to import action.", |
| 302 | setIconPickerOpen(false); | 129 | }); |
| 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); | ||
| 309 | } | 130 | } |
| 310 | }; | 131 | }; |
| 311 | 132 | ||
| ... | @@ -466,184 +287,13 @@ export default function ProjectScriptsControl({ | ... | @@ -466,184 +287,13 @@ export default function ProjectScriptsControl({ |
| 466 | </Tooltip> | 287 | </Tooltip> |
| 467 | )} | 288 | )} |
| 468 | 289 | ||
| 469 | <Dialog | 290 | <ProjectScriptEditorDialog |
| 470 | onOpenChange={(open) => { | 291 | request={editorRequest} |
| 471 | setDialogOpen(open); | 292 | scripts={scripts} |
| 472 | if (!open) { | 293 | onSubmit={submitScript} |
| 473 | setIconPickerOpen(false); | 294 | onDelete={(scriptId) => void onDeleteScript(scriptId)} |
| 474 | } | 295 | onClose={() => setEditorRequest(null)} |
| 475 | }} | 296 | /> |
| 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> | ||
| 647 | </> | 297 | </> |
| 648 | ); | 298 | ); |
| 649 | } | 299 | } |
apps/web/src/components/Sidebar.tsx+11-365| ... | @@ -28,7 +28,7 @@ import { | ... | @@ -28,7 +28,7 @@ import { |
| 28 | scopeThreadRef, | 28 | scopeThreadRef, |
| 29 | scopedThreadKey, | 29 | scopedThreadKey, |
| 30 | } from "@t3tools/client-runtime/environment"; | 30 | } from "@t3tools/client-runtime/environment"; |
| 31 | import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; | 31 | import type { ScopedThreadRef } from "@t3tools/contracts"; |
| 32 | import type { TimestampFormat } from "@t3tools/contracts/settings"; | 32 | import type { TimestampFormat } from "@t3tools/contracts/settings"; |
| 33 | import { | 33 | import { |
| 34 | AlarmClockIcon, | 34 | AlarmClockIcon, |
| ... | @@ -39,7 +39,6 @@ import { | ... | @@ -39,7 +39,6 @@ import { |
| 39 | CircleCheckIcon, | 39 | CircleCheckIcon, |
| 40 | CircleDashedIcon, | 40 | CircleDashedIcon, |
| 41 | ClockIcon, | 41 | ClockIcon, |
| 42 | CopyIcon, | ||
| 43 | FolderIcon, | 42 | FolderIcon, |
| 44 | FolderPlusIcon, | 43 | FolderPlusIcon, |
| 45 | GitBranchIcon, | 44 | GitBranchIcon, |
| ... | @@ -51,7 +50,6 @@ import { | ... | @@ -51,7 +50,6 @@ import { |
| 51 | ServerIcon, | 50 | ServerIcon, |
| 52 | SquarePenIcon, | 51 | SquarePenIcon, |
| 53 | TerminalIcon, | 52 | TerminalIcon, |
| 54 | Trash2Icon, | ||
| 55 | Undo2Icon, | 53 | Undo2Icon, |
| 56 | XIcon, | 54 | XIcon, |
| 57 | } from "lucide-react"; | 55 | } from "lucide-react"; |
| ... | @@ -89,14 +87,9 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina | ... | @@ -89,14 +87,9 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina |
| 89 | import { isMacPlatform } from "~/lib/utils"; | 87 | import { isMacPlatform } from "~/lib/utils"; |
| 90 | import { useOpenPrLink } from "../lib/openPullRequestLink"; | 88 | import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| 91 | import { readLocalApi } from "../localApi"; | 89 | import { readLocalApi } from "../localApi"; |
| 92 | import { | 90 | import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; |
| 93 | deriveProjectGroupingOverrideKey, | ||
| 94 | getProjectOrderKey, | ||
| 95 | selectProjectGroupingSettings, | ||
| 96 | } from "../logicalProject"; | ||
| 97 | import { | 91 | import { |
| 98 | buildSidebarProjectSnapshots, | 92 | buildSidebarProjectSnapshots, |
| 99 | type SidebarProjectGroupMember, | ||
| 100 | type SidebarProjectSnapshot, | 93 | type SidebarProjectSnapshot, |
| 101 | } from "../sidebarProjectGrouping"; | 94 | } from "../sidebarProjectGrouping"; |
| 102 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; | 95 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; |
| ... | @@ -105,7 +98,7 @@ import { useThreadActions } from "../hooks/useThreadActions"; | ... | @@ -105,7 +98,7 @@ import { useThreadActions } from "../hooks/useThreadActions"; |
| 105 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 98 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 106 | import { openCommandPalette } from "../commandPaletteBus"; | 99 | import { openCommandPalette } from "../commandPaletteBus"; |
| 107 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; | 100 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 108 | import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; | 101 | import { useClientSettings } from "../hooks/useSettings"; |
| 109 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; | 102 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; |
| 110 | import { useNowMinute } from "../hooks/useNowMinute"; | 103 | import { useNowMinute } from "../hooks/useNowMinute"; |
| 111 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; | 104 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| ... | @@ -113,7 +106,6 @@ import { useProjects, useThreadShells } from "../state/entities"; | ... | @@ -113,7 +106,6 @@ import { useProjects, useThreadShells } from "../state/entities"; |
| 113 | import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; | 106 | import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; |
| 114 | import { vcsEnvironment } from "../state/vcs"; | 107 | import { vcsEnvironment } from "../state/vcs"; |
| 115 | import { threadEnvironment } from "../state/threads"; | 108 | import { threadEnvironment } from "../state/threads"; |
| 116 | import { projectEnvironment } from "../state/projects"; | ||
| 117 | import { useEnvironmentQuery } from "../state/query"; | 109 | import { useEnvironmentQuery } from "../state/query"; |
| 118 | import { useAtomCommand } from "../state/use-atom-command"; | 110 | import { useAtomCommand } from "../state/use-atom-command"; |
| 119 | import { | 111 | import { |
| ... | @@ -138,7 +130,6 @@ import { | ... | @@ -138,7 +130,6 @@ import { |
| 138 | resolveSidebarThreadStatus, | 130 | resolveSidebarThreadStatus, |
| 139 | searchSidebarThreadsByTitle, | 131 | searchSidebarThreadsByTitle, |
| 140 | resolveWorkingStartedAt, | 132 | resolveWorkingStartedAt, |
| 141 | shouldNavigateAfterProjectRemoval, | ||
| 142 | sortLogicalProjectsForSidebar, | 133 | sortLogicalProjectsForSidebar, |
| 143 | sortPinnedThreadsForSidebar, | 134 | sortPinnedThreadsForSidebar, |
| 144 | sortSettledThreadsForSidebar, | 135 | sortSettledThreadsForSidebar, |
| ... | @@ -166,18 +157,8 @@ import { primaryServerProvidersAtom } from "../state/server"; | ... | @@ -166,18 +157,8 @@ import { primaryServerProvidersAtom } from "../state/server"; |
| 166 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; | 157 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 167 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 158 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 168 | import { Button } from "./ui/button"; | 159 | 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"; | ||
| 178 | import { Input } from "./ui/input"; | 160 | import { Input } from "./ui/input"; |
| 179 | import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; | 161 | import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; |
| 180 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | ||
| 181 | import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; | 162 | import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; |
| 182 | import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; | 163 | import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; |
| 183 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; | 164 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; |
| ... | @@ -188,11 +169,6 @@ import { useComposerDraftStore } from "../composerDraftStore"; | ... | @@ -188,11 +169,6 @@ import { useComposerDraftStore } from "../composerDraftStore"; |
| 188 | // stays behind an explicit Show more. | 169 | // stays behind an explicit Show more. |
| 189 | const SETTLED_TAIL_INITIAL_COUNT = 10; | 170 | const SETTLED_TAIL_INITIAL_COUNT = 10; |
| 190 | const SETTLED_TAIL_PAGE_COUNT = 25; | 171 | 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 | }; | ||
| 196 | 172 | ||
| 197 | function compactSidebarTimeLabel(label: string): string { | 173 | function compactSidebarTimeLabel(label: string): string { |
| 198 | if (label === "just now") return "now"; | 174 | if (label === "just now") return "now"; |
| ... | @@ -1379,13 +1355,6 @@ export default function Sidebar() { | ... | @@ -1379,13 +1355,6 @@ export default function Sidebar() { |
| 1379 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | 1355 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { |
| 1380 | reportFailure: false, | 1356 | reportFailure: false, |
| 1381 | }); | 1357 | }); |
| 1382 | const deleteProject = useAtomCommand(projectEnvironment.delete, { | ||
| 1383 | reportFailure: false, | ||
| 1384 | }); | ||
| 1385 | const updateProject = useAtomCommand(projectEnvironment.update, { | ||
| 1386 | reportFailure: false, | ||
| 1387 | }); | ||
| 1388 | const updateSettings = useUpdateClientSettings(); | ||
| 1389 | const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ | 1358 | const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ |
| 1390 | onCopy: ({ path }) => { | 1359 | onCopy: ({ path }) => { |
| 1391 | toastManager.add({ | 1360 | toastManager.add({ |
| ... | @@ -1423,9 +1392,6 @@ export default function Sidebar() { | ... | @@ -1423,9 +1392,6 @@ export default function Sidebar() { |
| 1423 | ); | 1392 | ); |
| 1424 | }, | 1393 | }, |
| 1425 | }); | 1394 | }); |
| 1426 | const [projectActionsTarget, setProjectActionsTarget] = useState<SidebarProjectSnapshot | null>( | ||
| 1427 | null, | ||
| 1428 | ); | ||
| 1429 | const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); | 1395 | const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); |
| 1430 | const newThreadContext = useHandleNewThread(); | 1396 | const newThreadContext = useHandleNewThread(); |
| 1431 | const openAddProjectCommandPalette = useCallback( | 1397 | const openAddProjectCommandPalette = useCallback( |
| ... | @@ -1602,156 +1568,20 @@ export default function Sidebar() { | ... | @@ -1602,156 +1568,20 @@ export default function Sidebar() { |
| 1602 | clearSelection(); | 1568 | clearSelection(); |
| 1603 | }, [clearSelection, projectScopeKey]); | 1569 | }, [clearSelection, projectScopeKey]); |
| 1604 | 1570 | ||
| 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 | |||
| 1747 | const handleProjectActions = useCallback( | 1571 | const handleProjectActions = useCallback( |
| 1748 | (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => { | 1572 | (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => { |
| 1749 | event.preventDefault(); | 1573 | event.preventDefault(); |
| 1750 | event.stopPropagation(); | 1574 | event.stopPropagation(); |
| 1751 | setProjectScopeMenuOpen(false); | 1575 | 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 | }); | ||
| 1753 | }, | 1583 | }, |
| 1754 | [], | 1584 | [isMobile, router, setOpenMobile], |
| 1755 | ); | 1585 | ); |
| 1756 | 1586 | ||
| 1757 | // Settled threads stay in the live shell stream (settled ≠ archived), so | 1587 | // Settled threads stay in the live shell stream (settled ≠ archived), so |
| ... | @@ -3545,190 +3375,6 @@ export default function Sidebar() { | ... | @@ -3545,190 +3375,6 @@ export default function Sidebar() { |
| 3545 | ) : null} | 3375 | ) : null} |
| 3546 | </SidebarGroup> | 3376 | </SidebarGroup> |
| 3547 | </SidebarContent> | 3377 | </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> | ||
| 3732 | <SidebarChromeFooter /> | 3378 | <SidebarChromeFooter /> |
| 3733 | </> | 3379 | </> |
| 3734 | ); | 3380 | ); |
apps/web/src/components/projectScriptEditor.tsx created+415| ... | @@ -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 created+1011| ... | @@ -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 { | ... | @@ -11,6 +11,7 @@ import { |
| 11 | ArchiveIcon, | 11 | ArchiveIcon, |
| 12 | ArrowLeftIcon, | 12 | ArrowLeftIcon, |
| 13 | BotIcon, | 13 | BotIcon, |
| 14 | FolderIcon, | ||
| 14 | GitBranchIcon, | 15 | GitBranchIcon, |
| 15 | KeyboardIcon, | 16 | KeyboardIcon, |
| 16 | Link2Icon, | 17 | Link2Icon, |
| ... | @@ -48,6 +49,7 @@ const SETTINGS_SECTION_ICONS: Readonly< | ... | @@ -48,6 +49,7 @@ const SETTINGS_SECTION_ICONS: Readonly< |
| 48 | "/settings/general": Settings2Icon, | 49 | "/settings/general": Settings2Icon, |
| 49 | "/settings/appearance": PaletteIcon, | 50 | "/settings/appearance": PaletteIcon, |
| 50 | "/settings/keybindings": KeyboardIcon, | 51 | "/settings/keybindings": KeyboardIcon, |
| 52 | "/settings/projects": FolderIcon, | ||
| 51 | "/settings/providers": BotIcon, | 53 | "/settings/providers": BotIcon, |
| 52 | "/settings/source-control": GitBranchIcon, | 54 | "/settings/source-control": GitBranchIcon, |
| 53 | "/settings/connections": Link2Icon, | 55 | "/settings/connections": Link2Icon, |
| ... | @@ -279,7 +281,9 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { | ... | @@ -279,7 +281,9 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 279 | )) | 281 | )) |
| 280 | : SETTINGS_NAV_ITEMS.map((item) => { | 282 | : SETTINGS_NAV_ITEMS.map((item) => { |
| 281 | const Icon = item.icon; | 283 | 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}/`); | ||
| 283 | return ( | 287 | return ( |
| 284 | <SidebarMenuItem key={item.to}> | 288 | <SidebarMenuItem key={item.to}> |
| 285 | <SidebarMenuButton | 289 | <SidebarMenuButton |
apps/web/src/components/settings/settingsSearch.ts+22| ... | @@ -2,6 +2,7 @@ export type SettingsPath = | ... | @@ -2,6 +2,7 @@ export type SettingsPath = |
| 2 | | "/settings/general" | 2 | | "/settings/general" |
| 3 | | "/settings/appearance" | 3 | | "/settings/appearance" |
| 4 | | "/settings/keybindings" | 4 | | "/settings/keybindings" |
| 5 | | "/settings/projects" | ||
| 5 | | "/settings/providers" | 6 | | "/settings/providers" |
| 6 | | "/settings/source-control" | 7 | | "/settings/source-control" |
| 7 | | "/settings/connections" | 8 | | "/settings/connections" |
| ... | @@ -22,6 +23,7 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = { | ... | @@ -22,6 +23,7 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = { |
| 22 | "/settings/general": "General", | 23 | "/settings/general": "General", |
| 23 | "/settings/appearance": "Appearance", | 24 | "/settings/appearance": "Appearance", |
| 24 | "/settings/keybindings": "Keybindings", | 25 | "/settings/keybindings": "Keybindings", |
| 26 | "/settings/projects": "Projects", | ||
| 25 | "/settings/providers": "Providers", | 27 | "/settings/providers": "Providers", |
| 26 | "/settings/source-control": "Source Control", | 28 | "/settings/source-control": "Source Control", |
| 27 | "/settings/connections": "Connections", | 29 | "/settings/connections": "Connections", |
| ... | @@ -174,6 +176,26 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -174,6 +176,26 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 174 | title: "Keybindings", | 176 | title: "Keybindings", |
| 175 | to: "/settings/keybindings", | 177 | to: "/settings/keybindings", |
| 176 | }, | 178 | }, |
| 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 | }, | ||
| 177 | { | 199 | { |
| 178 | id: "providers", | 200 | id: "providers", |
| 179 | title: "Providers", | 201 | title: "Providers", |
apps/web/src/hooks/useT3ProjectFileScripts.ts+36-8| ... | @@ -14,6 +14,41 @@ const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson); | ... | @@ -14,6 +14,41 @@ const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson); |
| 14 | 14 | ||
| 15 | const NO_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = []; | 15 | const NO_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = []; |
| 16 | 16 | ||
| 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 | |||
| 17 | /** | 52 | /** |
| 18 | * Scripts declared in the project's checked-in `t3.json`, offered in the | 53 | * Scripts declared in the project's checked-in `t3.json`, offered in the |
| 19 | * scripts menu for import. Missing, truncated, or invalid files resolve to | 54 | * scripts menu for import. Missing, truncated, or invalid files resolve to |
| ... | @@ -23,12 +58,5 @@ export function useT3ProjectFileScripts( | ... | @@ -23,12 +58,5 @@ export function useT3ProjectFileScripts( |
| 23 | environmentId: EnvironmentId, | 58 | environmentId: EnvironmentId, |
| 24 | cwd: string | null, | 59 | cwd: string | null, |
| 25 | ): ReadonlyArray<T3ProjectFileScript> { | 60 | ): ReadonlyArray<T3ProjectFileScript> { |
| 26 | const query = useProjectFileQuery(environmentId, cwd ?? "", T3_PROJECT_FILE_NAME, cwd !== null); | 61 | return useT3ProjectFileState(environmentId, cwd).scripts; |
| 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]); | ||
| 34 | } | 62 | } |
apps/web/src/routeTree.gen.ts+43| ... | @@ -17,6 +17,7 @@ import { Route as ChatRouteImport } from './routes/_chat' | ... | @@ -17,6 +17,7 @@ import { Route as ChatRouteImport } from './routes/_chat' |
| 17 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' | 17 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 18 | import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' | 18 | import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' |
| 19 | import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' | 19 | import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' |
| 20 | import { Route as SettingsProjectsRouteImport } from './routes/settings.projects' | ||
| 20 | import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' | 21 | import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' |
| 21 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' | 22 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| 22 | import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' | 23 | import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' |
| ... | @@ -24,6 +25,7 @@ import { Route as SettingsConnectionsRouteImport } from './routes/settings.conne | ... | @@ -24,6 +25,7 @@ import { Route as SettingsConnectionsRouteImport } from './routes/settings.conne |
| 24 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' | 25 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' |
| 25 | import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' | 26 | import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' |
| 26 | import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' | 27 | import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' |
| 28 | import { Route as SettingsProjectsProjectKeyRouteImport } from './routes/settings.projects_.$projectKey' | ||
| 27 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' | 29 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' |
| 28 | import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' | 30 | import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' |
| 29 | 31 | ||
| ... | @@ -66,6 +68,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ | ... | @@ -66,6 +68,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ |
| 66 | path: '/providers', | 68 | path: '/providers', |
| 67 | getParentRoute: () => SettingsRoute, | 69 | getParentRoute: () => SettingsRoute, |
| 68 | } as any) | 70 | } as any) |
| 71 | const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ | ||
| 72 | id: '/projects', | ||
| 73 | path: '/projects', | ||
| 74 | getParentRoute: () => SettingsRoute, | ||
| 75 | } as any) | ||
| 69 | const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ | 76 | const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ |
| 70 | id: '/keybindings', | 77 | id: '/keybindings', |
| 71 | path: '/keybindings', | 78 | path: '/keybindings', |
| ... | @@ -101,6 +108,12 @@ const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ | ... | @@ -101,6 +108,12 @@ const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ |
| 101 | path: '/connect/callback', | 108 | path: '/connect/callback', |
| 102 | getParentRoute: () => rootRouteImport, | 109 | getParentRoute: () => rootRouteImport, |
| 103 | } as any) | 110 | } as any) |
| 111 | const SettingsProjectsProjectKeyRoute = | ||
| 112 | SettingsProjectsProjectKeyRouteImport.update({ | ||
| 113 | id: '/projects_/$projectKey', | ||
| 114 | path: '/projects/$projectKey', | ||
| 115 | getParentRoute: () => SettingsRoute, | ||
| 116 | } as any) | ||
| 104 | const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ | 117 | const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ |
| 105 | id: '/draft/$draftId', | 118 | id: '/draft/$draftId', |
| 106 | path: '/draft/$draftId', | 119 | path: '/draft/$draftId', |
| ... | @@ -126,10 +139,12 @@ export interface FileRoutesByFullPath { | ... | @@ -126,10 +139,12 @@ export interface FileRoutesByFullPath { |
| 126 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute | 139 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 127 | '/settings/general': typeof SettingsGeneralRoute | 140 | '/settings/general': typeof SettingsGeneralRoute |
| 128 | '/settings/keybindings': typeof SettingsKeybindingsRoute | 141 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 142 | '/settings/projects': typeof SettingsProjectsRoute | ||
| 129 | '/settings/providers': typeof SettingsProvidersRoute | 143 | '/settings/providers': typeof SettingsProvidersRoute |
| 130 | '/settings/source-control': typeof SettingsSourceControlRoute | 144 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 131 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 145 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 132 | '/draft/$draftId': typeof ChatDraftDraftIdRoute | 146 | '/draft/$draftId': typeof ChatDraftDraftIdRoute |
| 147 | '/settings/projects/$projectKey': typeof SettingsProjectsProjectKeyRoute | ||
| 133 | } | 148 | } |
| 134 | export interface FileRoutesByTo { | 149 | export interface FileRoutesByTo { |
| 135 | '/connect': typeof ConnectRoute | 150 | '/connect': typeof ConnectRoute |
| ... | @@ -143,11 +158,13 @@ export interface FileRoutesByTo { | ... | @@ -143,11 +158,13 @@ export interface FileRoutesByTo { |
| 143 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute | 158 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 144 | '/settings/general': typeof SettingsGeneralRoute | 159 | '/settings/general': typeof SettingsGeneralRoute |
| 145 | '/settings/keybindings': typeof SettingsKeybindingsRoute | 160 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 161 | '/settings/projects': typeof SettingsProjectsRoute | ||
| 146 | '/settings/providers': typeof SettingsProvidersRoute | 162 | '/settings/providers': typeof SettingsProvidersRoute |
| 147 | '/settings/source-control': typeof SettingsSourceControlRoute | 163 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 148 | '/': typeof ChatIndexRoute | 164 | '/': typeof ChatIndexRoute |
| 149 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 165 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 150 | '/draft/$draftId': typeof ChatDraftDraftIdRoute | 166 | '/draft/$draftId': typeof ChatDraftDraftIdRoute |
| 167 | '/settings/projects/$projectKey': typeof SettingsProjectsProjectKeyRoute | ||
| 151 | } | 168 | } |
| 152 | export interface FileRoutesById { | 169 | export interface FileRoutesById { |
| 153 | __root__: typeof rootRouteImport | 170 | __root__: typeof rootRouteImport |
| ... | @@ -163,11 +180,13 @@ export interface FileRoutesById { | ... | @@ -163,11 +180,13 @@ export interface FileRoutesById { |
| 163 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute | 180 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 164 | '/settings/general': typeof SettingsGeneralRoute | 181 | '/settings/general': typeof SettingsGeneralRoute |
| 165 | '/settings/keybindings': typeof SettingsKeybindingsRoute | 182 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 183 | '/settings/projects': typeof SettingsProjectsRoute | ||
| 166 | '/settings/providers': typeof SettingsProvidersRoute | 184 | '/settings/providers': typeof SettingsProvidersRoute |
| 167 | '/settings/source-control': typeof SettingsSourceControlRoute | 185 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 168 | '/_chat/': typeof ChatIndexRoute | 186 | '/_chat/': typeof ChatIndexRoute |
| 169 | '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 187 | '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 170 | '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute | 188 | '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute |
| 189 | '/settings/projects_/$projectKey': typeof SettingsProjectsProjectKeyRoute | ||
| 171 | } | 190 | } |
| 172 | export interface FileRouteTypes { | 191 | export interface FileRouteTypes { |
| 173 | fileRoutesByFullPath: FileRoutesByFullPath | 192 | fileRoutesByFullPath: FileRoutesByFullPath |
| ... | @@ -184,10 +203,12 @@ export interface FileRouteTypes { | ... | @@ -184,10 +203,12 @@ export interface FileRouteTypes { |
| 184 | | '/settings/diagnostics' | 203 | | '/settings/diagnostics' |
| 185 | | '/settings/general' | 204 | | '/settings/general' |
| 186 | | '/settings/keybindings' | 205 | | '/settings/keybindings' |
| 206 | | '/settings/projects' | ||
| 187 | | '/settings/providers' | 207 | | '/settings/providers' |
| 188 | | '/settings/source-control' | 208 | | '/settings/source-control' |
| 189 | | '/$environmentId/$threadId' | 209 | | '/$environmentId/$threadId' |
| 190 | | '/draft/$draftId' | 210 | | '/draft/$draftId' |
| 211 | | '/settings/projects/$projectKey' | ||
| 191 | fileRoutesByTo: FileRoutesByTo | 212 | fileRoutesByTo: FileRoutesByTo |
| 192 | to: | 213 | to: |
| 193 | | '/connect' | 214 | | '/connect' |
| ... | @@ -201,11 +222,13 @@ export interface FileRouteTypes { | ... | @@ -201,11 +222,13 @@ export interface FileRouteTypes { |
| 201 | | '/settings/diagnostics' | 222 | | '/settings/diagnostics' |
| 202 | | '/settings/general' | 223 | | '/settings/general' |
| 203 | | '/settings/keybindings' | 224 | | '/settings/keybindings' |
| 225 | | '/settings/projects' | ||
| 204 | | '/settings/providers' | 226 | | '/settings/providers' |
| 205 | | '/settings/source-control' | 227 | | '/settings/source-control' |
| 206 | | '/' | 228 | | '/' |
| 207 | | '/$environmentId/$threadId' | 229 | | '/$environmentId/$threadId' |
| 208 | | '/draft/$draftId' | 230 | | '/draft/$draftId' |
| 231 | | '/settings/projects/$projectKey' | ||
| 209 | id: | 232 | id: |
| 210 | | '__root__' | 233 | | '__root__' |
| 211 | | '/_chat' | 234 | | '/_chat' |
| ... | @@ -220,11 +243,13 @@ export interface FileRouteTypes { | ... | @@ -220,11 +243,13 @@ export interface FileRouteTypes { |
| 220 | | '/settings/diagnostics' | 243 | | '/settings/diagnostics' |
| 221 | | '/settings/general' | 244 | | '/settings/general' |
| 222 | | '/settings/keybindings' | 245 | | '/settings/keybindings' |
| 246 | | '/settings/projects' | ||
| 223 | | '/settings/providers' | 247 | | '/settings/providers' |
| 224 | | '/settings/source-control' | 248 | | '/settings/source-control' |
| 225 | | '/_chat/' | 249 | | '/_chat/' |
| 226 | | '/_chat/$environmentId/$threadId' | 250 | | '/_chat/$environmentId/$threadId' |
| 227 | | '/_chat/draft/$draftId' | 251 | | '/_chat/draft/$draftId' |
| 252 | | '/settings/projects_/$projectKey' | ||
| 228 | fileRoutesById: FileRoutesById | 253 | fileRoutesById: FileRoutesById |
| 229 | } | 254 | } |
| 230 | export interface RootRouteChildren { | 255 | export interface RootRouteChildren { |
| ... | @@ -294,6 +319,13 @@ declare module '@tanstack/react-router' { | ... | @@ -294,6 +319,13 @@ declare module '@tanstack/react-router' { |
| 294 | preLoaderRoute: typeof SettingsProvidersRouteImport | 319 | preLoaderRoute: typeof SettingsProvidersRouteImport |
| 295 | parentRoute: typeof SettingsRoute | 320 | parentRoute: typeof SettingsRoute |
| 296 | } | 321 | } |
| 322 | '/settings/projects': { | ||
| 323 | id: '/settings/projects' | ||
| 324 | path: '/projects' | ||
| 325 | fullPath: '/settings/projects' | ||
| 326 | preLoaderRoute: typeof SettingsProjectsRouteImport | ||
| 327 | parentRoute: typeof SettingsRoute | ||
| 328 | } | ||
| 297 | '/settings/keybindings': { | 329 | '/settings/keybindings': { |
| 298 | id: '/settings/keybindings' | 330 | id: '/settings/keybindings' |
| 299 | path: '/keybindings' | 331 | path: '/keybindings' |
| ... | @@ -343,6 +375,13 @@ declare module '@tanstack/react-router' { | ... | @@ -343,6 +375,13 @@ declare module '@tanstack/react-router' { |
| 343 | preLoaderRoute: typeof ConnectCallbackRouteImport | 375 | preLoaderRoute: typeof ConnectCallbackRouteImport |
| 344 | parentRoute: typeof rootRouteImport | 376 | parentRoute: typeof rootRouteImport |
| 345 | } | 377 | } |
| 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 | } | ||
| 346 | '/_chat/draft/$draftId': { | 385 | '/_chat/draft/$draftId': { |
| 347 | id: '/_chat/draft/$draftId' | 386 | id: '/_chat/draft/$draftId' |
| 348 | path: '/draft/$draftId' | 387 | path: '/draft/$draftId' |
| ... | @@ -381,8 +420,10 @@ interface SettingsRouteChildren { | ... | @@ -381,8 +420,10 @@ interface SettingsRouteChildren { |
| 381 | SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute | 420 | SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute |
| 382 | SettingsGeneralRoute: typeof SettingsGeneralRoute | 421 | SettingsGeneralRoute: typeof SettingsGeneralRoute |
| 383 | SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute | 422 | SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute |
| 423 | SettingsProjectsRoute: typeof SettingsProjectsRoute | ||
| 384 | SettingsProvidersRoute: typeof SettingsProvidersRoute | 424 | SettingsProvidersRoute: typeof SettingsProvidersRoute |
| 385 | SettingsSourceControlRoute: typeof SettingsSourceControlRoute | 425 | SettingsSourceControlRoute: typeof SettingsSourceControlRoute |
| 426 | SettingsProjectsProjectKeyRoute: typeof SettingsProjectsProjectKeyRoute | ||
| 386 | } | 427 | } |
| 387 | 428 | ||
| 388 | const SettingsRouteChildren: SettingsRouteChildren = { | 429 | const SettingsRouteChildren: SettingsRouteChildren = { |
| ... | @@ -392,8 +433,10 @@ const SettingsRouteChildren: SettingsRouteChildren = { | ... | @@ -392,8 +433,10 @@ const SettingsRouteChildren: SettingsRouteChildren = { |
| 392 | SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, | 433 | SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, |
| 393 | SettingsGeneralRoute: SettingsGeneralRoute, | 434 | SettingsGeneralRoute: SettingsGeneralRoute, |
| 394 | SettingsKeybindingsRoute: SettingsKeybindingsRoute, | 435 | SettingsKeybindingsRoute: SettingsKeybindingsRoute, |
| 436 | SettingsProjectsRoute: SettingsProjectsRoute, | ||
| 395 | SettingsProvidersRoute: SettingsProvidersRoute, | 437 | SettingsProvidersRoute: SettingsProvidersRoute, |
| 396 | SettingsSourceControlRoute: SettingsSourceControlRoute, | 438 | SettingsSourceControlRoute: SettingsSourceControlRoute, |
| 439 | SettingsProjectsProjectKeyRoute: SettingsProjectsProjectKeyRoute, | ||
| 397 | } | 440 | } |
| 398 | 441 | ||
| 399 | const SettingsRouteWithChildren = SettingsRoute._addFileChildren( | 442 | const SettingsRouteWithChildren = SettingsRoute._addFileChildren( |
apps/web/src/routes/settings.projects.tsx created+11| ... | @@ -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 created+12| ... | @@ -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 | }); | ||