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/project-custom-presets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gemstack/framework': minor
---

Custom presets can now be saved to a project, not just to you. When you create a preset with a project open, a "Save to" choice lets you keep it private (as before) or commit it into the repo's `.the-framework/custom-presets.json`, so everyone who clones the project gets it. Shared presets show up in their own "Project presets" group in the Presets menu and under `/` in the editor, and delete from there. Personal presets still live in your home config and follow you across every project.
4 changes: 4 additions & 0 deletions packages/framework-dashboard/components/Composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ vi.mock('../lib/preferences.js', () => ({
// #842: the launcher strip reads the resolved layers; nothing here sets a repo tier.
usePreferenceSources: () => ({}),
useProjectFileConfig: () => ({}),
// #1025: project presets; nothing here opens a project, so no shared presets and no project scope.
useProjectPresets: () => [],
saveProjectPresetList: vi.fn(),
useActiveProjectId: () => null,
}))
// The editor picker (#727) detects installed editors over Telefunc; stub it to none in the test.
vi.mock('../lib/editors.js', () => ({ useDetectedEditors: () => [] }))
Expand Down
14 changes: 12 additions & 2 deletions packages/framework-dashboard/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
themePreference,
usePreferenceSources,
useProjectFileConfig,
useProjectPresets,
saveProjectPresetList,
useActiveProjectId,
} from '../lib/preferences.js'
import { useLoaded } from '../lib/use-async.js'
import { onProjects } from '../server/projects.telefunc.js'
Expand Down Expand Up @@ -147,6 +150,8 @@ export const Composer = forwardRef<ComposerHandle, {
// The stored agent as a display name; an unknown stored value falls back to Claude Code.
const agentLabel = AGENT_LABELS[AGENTS.includes(agent as AgentName) ? (agent as AgentName) : 'claude']
const customPresets = preferences.customPresets ?? [] // #626: the user's own saved prompts
const projectPresets = useProjectPresets() // #1025: presets committed in the open project's repo
const activeProjectId = useActiveProjectId() // #1025: a project to commit a shared preset into
const theme = themePreference(preferences) // #725: system (default) / light / dark

// Vanilla removes the system prompt (nothing left for Eco to trim); Transparent turns off the
Expand Down Expand Up @@ -240,6 +245,7 @@ export const Composer = forwardRef<ComposerHandle, {
files={files}
presets={presets}
customPresets={customPresets}
projectPresets={projectPresets}
// The `/` menu offers "New preset…" only in the full composer, where the create panel renders;
// the compact navbar launch has no panel, so it gets no callback (and no item).
{...(compact ? {} : { onNewPreset: () => setAddingPreset(true) })}
Expand Down Expand Up @@ -270,10 +276,12 @@ export const Composer = forwardRef<ComposerHandle, {
<PresetsMenu
presets={presets}
customPresets={customPresets}
projectPresets={projectPresets}
busy={busy}
onLoad={loadPresetFromMenu}
onNew={() => setAddingPreset(true)}
onDelete={id => updatePreferences({ customPresets: customPresets.filter(p => p.id !== id) })}
onDeleteProject={id => saveProjectPresetList(projectPresets.filter(p => p.id !== id))}
/>
)}
<OptionsMenu
Expand Down Expand Up @@ -358,12 +366,14 @@ export const Composer = forwardRef<ComposerHandle, {
<PresetCreatePanel
currentPrompt={prompt}
busy={busy}
canSaveToProject={activeProjectId !== null}
onCancel={() => {
setAddingPreset(false)
editorRef.current?.focus()
}}
onSave={preset => {
updatePreferences({ customPresets: [...customPresets, preset] })
onSave={(preset, scope) => {
if (scope === 'project') saveProjectPresetList([...projectPresets, preset])
else updatePreferences({ customPresets: [...customPresets, preset] })
setAddingPreset(false)
editorRef.current?.focus()
}}
Expand Down
24 changes: 20 additions & 4 deletions packages/framework-dashboard/components/PresetCreatePanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,39 @@ afterEach(cleanup)
describe('PresetCreatePanel (#649)', () => {
test('prefills the prompt from the editor and saves a well-formed preset', () => {
const onSave = vi.fn()
render(<PresetCreatePanel currentPrompt="my crafted prompt" busy={false} onSave={onSave} onCancel={() => {}} />)
render(<PresetCreatePanel currentPrompt="my crafted prompt" busy={false} canSaveToProject={false} onSave={onSave} onCancel={() => {}} />)
expect((screen.getByPlaceholderText(/prompt this preset runs/i) as HTMLTextAreaElement).value).toBe('my crafted prompt')
fireEvent.change(screen.getByPlaceholderText('Preset name'), { target: { value: 'My preset' } })
fireEvent.click(screen.getByRole('button', { name: 'Save preset' }))
const saved = onSave.mock.calls[0]![0] as CustomPreset
const [saved, scope] = onSave.mock.calls[0]! as [CustomPreset, string]
expect({ label: saved.label, prompt: saved.prompt }).toEqual({ label: 'My preset', prompt: 'my crafted prompt' })
expect(saved.id).toBeTruthy()
expect(scope).toBe('user') // no project open -> always the user tier (#1025)
})

test('cannot save without both a name and a prompt', () => {
render(<PresetCreatePanel currentPrompt="" busy={false} onSave={() => {}} onCancel={() => {}} />)
render(<PresetCreatePanel currentPrompt="" busy={false} canSaveToProject={false} onSave={() => {}} onCancel={() => {}} />)
expect((screen.getByRole('button', { name: 'Save preset' }) as HTMLButtonElement).disabled).toBe(true)
})

test('with a project open, saving "This project" reports the project scope (#1025)', () => {
const onSave = vi.fn()
render(<PresetCreatePanel currentPrompt="shared prompt" busy={false} canSaveToProject onSave={onSave} onCancel={() => {}} />)
fireEvent.change(screen.getByPlaceholderText('Preset name'), { target: { value: 'Team preset' } })
fireEvent.click(screen.getByRole('button', { name: 'This project' }))
fireEvent.click(screen.getByRole('button', { name: 'Save preset' }))
const [, scope] = onSave.mock.calls[0]! as [CustomPreset, string]
expect(scope).toBe('project')
})

test('no scope choice when there is no project to share into (#1025)', () => {
render(<PresetCreatePanel currentPrompt="" busy={false} canSaveToProject={false} onSave={() => {}} onCancel={() => {}} />)
expect(screen.queryByRole('button', { name: 'This project' })).toBeNull()
})

test('Cancel backs out', () => {
const onCancel = vi.fn()
render(<PresetCreatePanel currentPrompt="" busy={false} onSave={() => {}} onCancel={onCancel} />)
render(<PresetCreatePanel currentPrompt="" busy={false} canSaveToProject={false} onSave={() => {}} onCancel={onCancel} />)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
Expand Down
37 changes: 35 additions & 2 deletions packages/framework-dashboard/components/PresetCreatePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { Button } from './ui/button.js'

const LABEL_MAX = 80

/** Where a saved preset lives (#1025): private to the user, or committed into the project's repo. */
export type PresetScope = 'user' | 'project'

/** A fresh id for a saved preset. `crypto.randomUUID` is present in the browser + prerender runtime. */
function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `p-${Date.now()}`
Expand All @@ -16,23 +19,27 @@ function newId(): string {
export function PresetCreatePanel({
currentPrompt,
busy,
canSaveToProject,
onSave,
onCancel,
}: {
/** The editor's current text, prefilled so you can save what you just wrote. */
currentPrompt: string
busy: boolean
onSave: (preset: CustomPreset) => void
/** Whether a project is open to commit a shared preset into (#1025); hides the scope choice otherwise. */
canSaveToProject: boolean
onSave: (preset: CustomPreset, scope: PresetScope) => void
onCancel: () => void
}) {
const [label, setLabel] = useState('')
const [prompt, setPrompt] = useState(currentPrompt)
const [scope, setScope] = useState<PresetScope>('user')

const save = () => {
const trimmedLabel = label.trim()
const trimmedPrompt = prompt.trim()
if (!trimmedLabel || !trimmedPrompt) return
onSave({ id: newId(), label: trimmedLabel, prompt: trimmedPrompt })
onSave({ id: newId(), label: trimmedLabel, prompt: trimmedPrompt }, canSaveToProject ? scope : 'user')
}

// Keyboard parity with the composer (#948): Esc cancels, ⌘/Ctrl+Enter saves.
Expand Down Expand Up @@ -67,6 +74,32 @@ export function PresetCreatePanel({
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"
/>
{canSaveToProject && (
<div className="flex items-center gap-2 text-xs text-[var(--color-muted-foreground)]">
<span>Save to</span>
<div className="inline-flex overflow-hidden rounded-md border border-border">
{(['user', 'project'] as const).map(value => (
<button
key={value}
type="button"
disabled={busy}
onClick={() => setScope(value)}
aria-pressed={scope === value}
className={
scope === value
? 'bg-foreground px-2 py-0.5 text-background'
: 'px-2 py-0.5 text-foreground hover:bg-[var(--color-muted)]'
}
>
{value === 'user' ? 'Just me' : 'This project'}
</button>
))}
</div>
<span className="truncate">
{scope === 'project' ? 'Committed to the repo, shared with your team' : 'Private to you, on every project'}
</span>
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
Cancel
Expand Down
34 changes: 32 additions & 2 deletions packages/framework-dashboard/components/PresetsMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,28 @@ const presets = [
{ id: 'maintenance', label: '[Maintenance]', render: () => 'MAINTENANCE PROMPT' },
]
const custom = [{ id: 'c1', label: 'My sweep', prompt: 'sweep it' }]
const project = [{ id: 'p1', label: 'Team sweep', prompt: 'team it' }]

function mount(over: Partial<Parameters<typeof PresetsMenu>[0]> = {}) {
const onLoad = vi.fn()
const onNew = vi.fn()
const onDelete = vi.fn()
const onDeleteProject = vi.fn()
render(
<PresetsMenu presets={presets} customPresets={custom} busy={false} onLoad={onLoad} onNew={onNew} onDelete={onDelete} {...over} />,
<PresetsMenu
presets={presets}
customPresets={custom}
projectPresets={project}
busy={false}
onLoad={onLoad}
onNew={onNew}
onDelete={onDelete}
onDeleteProject={onDeleteProject}
{...over}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /presets/i }))
return { onLoad, onNew, onDelete }
return { onLoad, onNew, onDelete, onDeleteProject }
}

// #948: presets used to load only behind typing `/`, and delete lived in the options gear.
Expand All @@ -42,6 +54,24 @@ describe('PresetsMenu', () => {
expect(onLoad).not.toHaveBeenCalled()
})

test('a shared project preset loads verbatim (#1025)', () => {
const { onLoad } = mount()
fireEvent.click(screen.getByText('Team sweep'))
expect(onLoad).toHaveBeenCalledWith('team it', 'Team sweep')
})

test('a project preset deletes via its own handler (#1025)', () => {
const { onDeleteProject, onDelete } = mount()
fireEvent.click(screen.getByRole('button', { name: 'Delete preset Team sweep' }))
expect(onDeleteProject).toHaveBeenCalledWith('p1')
expect(onDelete).not.toHaveBeenCalled()
})

test('the Project presets group is hidden when there are none (#1025)', () => {
mount({ projectPresets: [] })
expect(screen.queryByText('Project presets')).toBeNull()
})

test('"New preset…" opens the create panel', () => {
const { onNew } = mount()
fireEvent.click(screen.getByText('New preset…'))
Expand Down
68 changes: 50 additions & 18 deletions packages/framework-dashboard/components/PresetsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,24 +28,63 @@ export interface PresetEntry {
// no sign that 13 launcher presets exist — and deleting lived in the options gear, a different
// menu. This button is the one surface that loads, creates, and deletes; the `/` menu stays as
// the fast path for those who know it.
/** One saved (user or project) preset row: click loads it, the X deletes it. */
function SavedPresetRow({
preset,
busy,
onLoad,
onDelete,
}: {
preset: CustomPreset
busy: boolean
onLoad: (text: string, label: string) => void
onDelete: (id: string) => void
}) {
return (
<DropdownMenuItem disabled={busy} onClick={() => onLoad(preset.prompt, preset.label)} className="items-center gap-2">
<span className="flex-1 truncate">{preset.label}</span>
<button
type="button"
disabled={busy}
onClick={e => {
// Delete, not load — keep the row's own click out of it.
e.stopPropagation()
onDelete(preset.id)
}}
title={`Delete "${preset.label}"`}
aria-label={`Delete preset ${preset.label}`}
className="rounded p-0.5 text-[var(--color-muted-foreground)] hover:text-danger"
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</DropdownMenuItem>
)
}

export function PresetsMenu({
presets,
customPresets,
projectPresets,
busy,
onLoad,
onNew,
onDelete,
onDeleteProject,
}: {
presets: PresetEntry[]
customPresets: CustomPreset[]
/** The open project's shared presets, committed in its `.the-framework/` (#1025). */
projectPresets: CustomPreset[]
busy: boolean
/** Load a preset's prompt into the editor. `newSession` marks the ones that never append to
* the open session (#959); a saved preset is always a plain load. */
onLoad: (text: string, label: string, newSession?: boolean) => void
/** Open the create panel; absent where no panel renders. */
onNew?: (() => void) | undefined
/** Delete a saved preset by id. */
/** Delete a saved user preset by id. */
onDelete: (id: string) => void
/** Delete a shared project preset by id. */
onDeleteProject: (id: string) => void
}) {
return (
<DropdownMenu>
Expand Down Expand Up @@ -78,23 +117,16 @@ export function PresetsMenu({
<DropdownMenuSeparator />
<DropdownMenuLabel>Your presets</DropdownMenuLabel>
{customPresets.map(p => (
<DropdownMenuItem key={p.id} disabled={busy} onClick={() => onLoad(p.prompt, p.label)} className="items-center gap-2">
<span className="flex-1 truncate">{p.label}</span>
<button
type="button"
disabled={busy}
onClick={e => {
// Delete, not load — keep the row's own click out of it.
e.stopPropagation()
onDelete(p.id)
}}
title={`Delete "${p.label}"`}
aria-label={`Delete preset ${p.label}`}
className="rounded p-0.5 text-[var(--color-muted-foreground)] hover:text-danger"
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</DropdownMenuItem>
<SavedPresetRow key={p.id} preset={p} busy={busy} onLoad={onLoad} onDelete={onDelete} />
))}
</DropdownMenuGroup>
)}
{projectPresets.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>Project presets</DropdownMenuLabel>
{projectPresets.map(p => (
<SavedPresetRow key={p.id} preset={p} busy={busy} onLoad={onLoad} onDelete={onDeleteProject} />
))}
</DropdownMenuGroup>
)}
Expand Down
Loading
Loading