Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/preset-create-dialog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gemstack/framework': patch
---

Dashboard: the "New preset" form is now a centered modal dialog instead of a panel that pushed the composer controls down. Same fields (name, prompt, and the "Save to" scope choice); a backdrop click, Esc, or the close button dismisses it. Adds a reusable shadcn-style `Dialog` on Base UI for future forms.
23 changes: 11 additions & 12 deletions packages/framework-dashboard/components/PresetCreatePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useState, type KeyboardEvent } from 'react'
import type { CustomPreset } from '@gemstack/framework'
import { Button } from './ui/button.js'
import { Dialog } from './ui/dialog.js'

// The "New preset" create panel (#649/#626), lifted out of the Presets dropdown so it renders
// full-width under the controls row. Prefills the prompt from the editor's current text — the
// common "save what I just wrote" path. A preset is just a label + a prompt.
// The "New preset" create dialog (#649/#626): a modal over the composer rather than a panel that
// pushed the controls down. Prefills the prompt from the editor's current text — the common "save
// what I just wrote" path. A preset is just a label + a prompt, plus where it is saved (#1025).

const LABEL_MAX = 80

Expand Down Expand Up @@ -42,20 +43,17 @@ export function PresetCreatePanel({
onSave({ id: newId(), label: trimmedLabel, prompt: trimmedPrompt }, canSaveToProject ? scope : 'user')
}

// Keyboard parity with the composer (#948): Esc cancels, ⌘/Ctrl+Enter saves.
// ⌘/Ctrl+Enter saves, matching the composer (#948); Esc closes via the Dialog itself.
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
save()
}
}

return (
<div className="mt-1.5 flex w-full flex-col gap-1.5 rounded-md border border-border p-2" onKeyDown={onKeyDown}>
<Dialog open onOpenChange={next => { if (!next) onCancel() }} title="New preset">
<div className="flex w-full flex-col gap-2" onKeyDown={onKeyDown}>
<input
type="text"
value={label}
Expand All @@ -70,7 +68,7 @@ export function PresetCreatePanel({
value={prompt}
placeholder="The prompt this preset runs…"
disabled={busy}
rows={4}
rows={5}
onChange={e => setPrompt(e.target.value)}
className="w-full resize-y rounded-md border border-border bg-background px-2 py-1 font-mono text-xs text-foreground"
/>
Expand Down Expand Up @@ -100,14 +98,15 @@ export function PresetCreatePanel({
</span>
</div>
)}
<div className="flex items-center justify-end gap-2">
<div className="mt-1 flex items-center justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button type="button" size="sm" disabled={busy || !label.trim() || !prompt.trim()} onClick={save}>
Save preset
</Button>
</div>
</div>
</div>
</Dialog>
)
}
42 changes: 42 additions & 0 deletions packages/framework-dashboard/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client'
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog'
import { X } from 'lucide-react'
import type { ReactNode } from 'react'

// The shadcn "base" dialog (Base UI, not Radix) — a centered modal shell for a small form (#1025).
// Unlike ConfirmDialog (AlertDialog, focus-trapped and no light-dismiss, for the one irreversible
// action), this is a plain Dialog: Esc and a backdrop click close it, since a half-filled form is
// not a commit you have to defend against. Controlled by the caller (`open`/`onOpenChange`), so the
// host owns when it shows; the header carries a title and a close affordance.

export function Dialog({
open,
onOpenChange,
title,
children,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: ReactNode
children: ReactNode
}) {
return (
<BaseDialog.Root open={open} onOpenChange={onOpenChange}>
<BaseDialog.Portal>
<BaseDialog.Backdrop className="fixed inset-0 z-50 bg-black/40 backdrop-blur-[1px]" />
<BaseDialog.Popup className="fixed left-1/2 top-1/2 z-50 w-[min(34rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-lg outline-none">
<div className="mb-3 flex items-center justify-between">
<BaseDialog.Title className="text-sm font-semibold">{title}</BaseDialog.Title>
<BaseDialog.Close
aria-label="Close"
className="rounded p-0.5 text-muted-foreground outline-none hover:text-foreground"
>
<X className="h-4 w-4" aria-hidden />
</BaseDialog.Close>
</div>
{children}
</BaseDialog.Popup>
</BaseDialog.Portal>
</BaseDialog.Root>
)
}
Loading