From 03898f9e0f1b400524dd37ea991383356745eecc Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 9 Sep 2026 08:42:25 +0000 Subject: [PATCH] feat: add named personal prompt templates --- apps/docs/fast-sessions.mdx | 19 ++ .../(authenticated)/home/Home.client.test.tsx | 63 ++++- apps/web/src/components/tasks/NewTaskForm.tsx | 28 ++- .../tasks/PromptTemplates.client.test.tsx | 151 ++++++++++++ .../src/components/tasks/PromptTemplates.tsx | 226 ++++++++++++++++++ 5 files changed, 475 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/tasks/PromptTemplates.client.test.tsx create mode 100644 apps/web/src/components/tasks/PromptTemplates.tsx diff --git a/apps/docs/fast-sessions.mdx b/apps/docs/fast-sessions.mdx index 6c5151638d..7611313f44 100644 --- a/apps/docs/fast-sessions.mdx +++ b/apps/docs/fast-sessions.mdx @@ -43,6 +43,25 @@ dashboard page. You do not need to choose a separate conversation mode. The Session grows from conversation to execution to review without changing identity. +### Reuse a prompt template + +On Home or in **New Session**, write a prompt, open **Templates**, enter a +template name, and select **Save current prompt**. Names must be unique within +your library (ignoring capitalization); to reuse a name, delete its existing +template first. Blank names and blank prompts cannot be saved. + +Open **Templates** and select a saved name to replace the composer's text. +Review or edit it, check any attachments and model settings, then send normally +to start a new Session. Loading a template does not start work. Use the delete +button beside a template to remove just that entry. + +Templates store the exact prompt text, including whitespace, but not attachments, +models, environments, or other settings. They are saved only in this browser for +your signed-in account, not shared with teammates or synced across devices. +Clearing browser storage removes them, and private browsing may discard them. +If browser storage is unavailable, full, or unreadable, Roomote reports the error +rather than claiming a save or deletion succeeded. + ## Source-control work without a workspace Fast uses existing integration APIs for bounded repository inspection when the diff --git a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx index 23940572fc..f1c4db120d 100644 --- a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx @@ -11,6 +11,7 @@ import type { PromptInputMessage } from '@/components/ai-elements'; let currentSearchParams = ''; let currentIsAdmin = true; +let currentReadOnly = false; let currentEnvironments: Array<{ id: string; name: string }> | undefined = [ { id: 'env-1', name: 'Primary Env' }, { id: 'env-2', name: 'Secondary Env' }, @@ -59,6 +60,7 @@ vi.mock('@/hooks/useUser', () => ({ }), useAuthorizedUser: () => ({ userId: 'user-1', + managedAccess: { state: currentReadOnly ? 'read_only' : 'active' }, isAdmin: currentIsAdmin, name: 'Test User', primaryEmail: 'test@example.com', @@ -160,8 +162,10 @@ vi.mock('@/components/tasks', async () => { if (submitDisabledReason) { return; } - onPromptTextChange?.('Test prompt'); - const result = onSubmit({ text: 'Test prompt', files: [] }); + const result = onSubmit({ + text: promptText || 'Test prompt', + files: [], + }); if (result instanceof Promise) { void result.catch(() => {}); @@ -227,6 +231,7 @@ describe('Home', () => { beforeEach(() => { currentSearchParams = ''; currentIsAdmin = true; + currentReadOnly = false; currentEnvironments = [ { id: 'env-1', name: 'Primary Env' }, { id: 'env-2', name: 'Secondary Env' }, @@ -269,6 +274,60 @@ describe('Home', () => { }); }); + it('loads a saved template into the shared composer, allows editing, then starts through the existing mutation', async () => { + const exact = ' Review \n\nKeep $variables. '; + render(); + const composer = screen.getByRole('textbox', { name: 'Task prompt' }); + fireEvent.change(composer, { target: { value: exact } }); + fireEvent.click(screen.getByRole('button', { name: 'Templates' })); + fireEvent.change(screen.getByRole('textbox', { name: 'Template name' }), { + target: { value: 'Code review' }, + }); + fireEvent.click( + screen.getByRole('button', { name: 'Save current prompt' }), + ); + fireEvent.change(composer, { target: { value: 'Replace this' } }); + fireEvent.click(screen.getByRole('button', { name: 'Load Code review' })); + expect(composer).toHaveValue(exact); + expect(mockStartFastSession).not.toHaveBeenCalled(); + fireEvent.change(composer, { + target: { value: `${exact}\nFocus on tests.` }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Submit prompt' })); + await waitFor(() => + expect(mockStartFastSession).toHaveBeenCalledWith({ + text: `${exact}\nFocus on tests.`.trim(), + images: undefined, + attachmentTexts: undefined, + model: undefined, + }), + ); + expect(mockStartFastSession).toHaveBeenCalledTimes(1); + expect(mockPush).toHaveBeenCalledWith('/sessions/fast-session-1'); + }); + + it('keeps managed-access launch restrictions after loading a template', () => { + currentReadOnly = true; + localStorage.setItem( + 'roomote-prompt-templates:v1:user-1', + JSON.stringify({ + version: 1, + templates: [{ name: 'Review', prompt: 'Review code' }], + }), + ); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Templates' })); + fireEvent.click(screen.getByRole('button', { name: 'Load Review' })); + expect(screen.getByRole('textbox', { name: 'Task prompt' })).toHaveValue( + 'Review code', + ); + expect( + screen.getByRole('button', { name: 'Submit prompt' }), + ).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Submit prompt' })); + expect(mockStartFastSession).not.toHaveBeenCalled(); + }); + it('leaves an untouched Fast session on the orchestration default', async () => { render(); diff --git a/apps/web/src/components/tasks/NewTaskForm.tsx b/apps/web/src/components/tasks/NewTaskForm.tsx index 21a6f6783d..71c426dd06 100644 --- a/apps/web/src/components/tasks/NewTaskForm.tsx +++ b/apps/web/src/components/tasks/NewTaskForm.tsx @@ -21,6 +21,7 @@ import { useStartFastSession } from '@/hooks/task-runs'; import type { PromptInputMessage } from '@/components/ai-elements'; import { SessionModelSwitcher, TaskPromptInput } from '@/components/tasks'; import { useTaskLaunchConfig } from '@/components/tasks/TaskLaunchConfig'; +import { PromptTemplates } from '@/components/tasks/PromptTemplates'; const DEFAULT_PROMPT_PLACEHOLDER = 'What do you want to do?'; @@ -238,16 +239,23 @@ export function NewTaskForm({ submitWithMetaKey={false} submitDisabledReason={submitDisabledReason} tools={ - - setSelectedModelOverrideId(model || undefined) - } - reasoningEffort={selectedReasoningEffort ?? null} - onReasoningEffortChange={setSelectedReasoningEffort} - defaultModelId={defaultModelId} - defaultReasoningEffort={defaultReasoningEffort} - /> + <> + + setSelectedModelOverrideId(model || undefined) + } + reasoningEffort={selectedReasoningEffort ?? null} + onReasoningEffortChange={setSelectedReasoningEffort} + defaultModelId={defaultModelId} + defaultReasoningEffort={defaultReasoningEffort} + /> + + } /> diff --git a/apps/web/src/components/tasks/PromptTemplates.client.test.tsx b/apps/web/src/components/tasks/PromptTemplates.client.test.tsx new file mode 100644 index 0000000000..2a850de890 --- /dev/null +++ b/apps/web/src/components/tasks/PromptTemplates.client.test.tsx @@ -0,0 +1,151 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { PromptTemplates } from './PromptTemplates'; + +let userId = 'user-1'; +vi.mock('@/hooks/useUser', () => ({ useAuthorizedUser: () => ({ userId }) })); + +const key = 'roomote-prompt-templates:v1:user-1'; +const prompt = ' Review this code\n\nKeep $variables and . '; +const open = () => + fireEvent.click(screen.getByRole('button', { name: 'Templates' })); +const save = (name: string) => { + fireEvent.change(screen.getByRole('textbox', { name: 'Template name' }), { + target: { value: name }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save current prompt' })); +}; +const stored = () => JSON.parse(localStorage.getItem(key) ?? 'null'); + +describe('PromptTemplates', () => { + beforeEach(() => { + userId = 'user-1'; + localStorage.clear(); + }); + afterEach(() => vi.restoreAllMocks()); + + it('saves named exact text, persists across mounts, loads without launching, and deletes one item', () => { + const onLoad = vi.fn(); + const first = render(); + open(); + save('Review'); + save('Other'); + expect(stored()).toEqual({ + version: 1, + templates: [ + { name: 'Review', prompt }, + { name: 'Other', prompt }, + ], + }); + expect(onLoad).not.toHaveBeenCalled(); + first.unmount(); + render(); + open(); + fireEvent.click(screen.getByRole('button', { name: 'Load Review' })); + expect(onLoad).toHaveBeenCalledWith(prompt); + open(); + fireEvent.click(screen.getByRole('button', { name: 'Delete Review' })); + expect(stored().templates).toEqual([{ name: 'Other', prompt }]); + }); + + it('rejects blank inputs and case-insensitive duplicate names without overwriting', () => { + const view = render(); + open(); + save('Review'); + expect(screen.getByRole('alert')).toHaveTextContent('non-empty prompt'); + view.rerender(); + save(' '); + expect(localStorage.getItem(key)).toBeNull(); + save('Review'); + save(' review '); + expect(screen.getByRole('alert')).toHaveTextContent('already exists'); + expect(stored().templates).toHaveLength(1); + }); + + it('keeps more than eight entries and re-reads before mutations and reopening', () => { + render(); + open(); + const other = Array.from({ length: 10 }, (_, i) => ({ + name: `Other ${i}`, + prompt: 'external', + })); + localStorage.setItem(key, JSON.stringify({ version: 1, templates: other })); + save('Mine'); + expect(stored().templates).toHaveLength(11); + localStorage.setItem( + key, + JSON.stringify({ version: 1, templates: [{ name: 'Newest', prompt }] }), + ); + open(); + open(); + expect( + screen.getByRole('button', { name: 'Load Newest' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Load Mine' }), + ).not.toBeInTheDocument(); + }); + + it('closes and discards displayed account state when the user changes', () => { + const view = render(); + open(); + save('Private'); + userId = 'user-2'; + view.rerender(); + expect(screen.queryByText('Private')).not.toBeInTheDocument(); + open(); + expect(screen.getByText('No saved templates.')).toBeInTheDocument(); + save('Second'); + expect(stored().templates).toEqual([{ name: 'Private', prompt }]); + }); + + it.each([ + '{bad', + '{"version":2,"templates":[]}', + '{"version":1,"templates":[{"name":"X","prompt":5}]}', + ])('reports malformed storage without overwriting it: %s', (raw) => { + localStorage.setItem(key, raw); + render(); + open(); + expect(screen.getByRole('alert')).toHaveTextContent('Could not read'); + save('New'); + expect(screen.getByRole('alert')).toHaveTextContent('Nothing was saved'); + expect(localStorage.getItem(key)).toBe(raw); + }); + + it('reports failed writes without showing a save or deletion as successful', () => { + render(); + open(); + save('Existing'); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota'); + }); + save('Failed'); + expect(screen.getByRole('alert')).toHaveTextContent('Nothing was saved'); + expect( + screen.queryByRole('button', { name: 'Load Failed' }), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Delete Existing' })); + expect(screen.getByRole('alert')).toHaveTextContent('Nothing was deleted'); + expect( + screen.getByRole('button', { name: 'Load Existing' }), + ).toBeInTheDocument(); + }); + + it('does not load stale deleted text and handles blocked reads', () => { + const onLoad = vi.fn(); + render(); + open(); + save('Removed'); + localStorage.removeItem(key); + fireEvent.click(screen.getByRole('button', { name: 'Load Removed' })); + expect(onLoad).not.toHaveBeenCalled(); + expect(screen.getByRole('alert')).toHaveTextContent('no longer exists'); + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blocked'); + }); + open(); + open(); + expect(screen.getByRole('alert')).toHaveTextContent('Could not read'); + }); +}); diff --git a/apps/web/src/components/tasks/PromptTemplates.tsx b/apps/web/src/components/tasks/PromptTemplates.tsx new file mode 100644 index 0000000000..780dde5d18 --- /dev/null +++ b/apps/web/src/components/tasks/PromptTemplates.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { useId, useState } from 'react'; +import { z } from 'zod'; + +import { useAuthorizedUser } from '@/hooks/useUser'; +import { + Button, + Input, + Label, + Popover, + PopoverContent, + PopoverTrigger, + Trash2, +} from '@/components/system'; + +const librarySchema = z.object({ + version: z.literal(1), + templates: z + .array( + z.object({ + name: z.string().trim().min(1), + prompt: z.string().refine((value) => value.trim().length > 0), + }), + ) + .refine( + (items) => + new Set(items.map((item) => item.name.toLowerCase())).size === + items.length, + ), +}); + +type Template = z.infer['templates'][number]; +type Props = { + prompt: string; + onLoad: (prompt: string) => void; + disabled?: boolean; +}; + +export function PromptTemplates(props: Props) { + const { userId } = useAuthorizedUser(); + return ; +} + +function UserPromptTemplates({ + userId, + prompt, + onLoad, + disabled, +}: Props & { userId: string }) { + const nameId = useId(); + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [templates, setTemplates] = useState([]); + const [error, setError] = useState(''); + const storageKey = `roomote-prompt-templates:v1:${userId}`; + + function readTemplates(): Template[] { + const raw = localStorage.getItem(storageKey); + return raw === null ? [] : librarySchema.parse(JSON.parse(raw)).templates; + } + + function changeOpen(nextOpen: boolean) { + if (nextOpen) { + setError(''); + try { + setTemplates(readTemplates()); + } catch { + setTemplates([]); + setError( + 'Could not read templates from this browser. Stored data has not been changed.', + ); + } + } + setOpen(nextOpen); + } + + function save() { + if (!name.trim() || !prompt.trim()) { + setError('Enter a template name and a non-empty prompt.'); + return; + } + try { + // Re-read before each action so another composer cannot overwrite newer entries. + const current = readTemplates(); + if ( + current.some( + (item) => item.name.toLowerCase() === name.trim().toLowerCase(), + ) + ) { + setTemplates(current); + setError( + 'That name already exists. Choose another name or delete the existing template first.', + ); + return; + } + const next = [...current, { name: name.trim(), prompt }]; + localStorage.setItem( + storageKey, + JSON.stringify({ version: 1, templates: next }), + ); + setTemplates(next); + setName(''); + setError(''); + } catch { + setError( + 'Could not save the template in this browser. Nothing was saved.', + ); + } + } + + function select(templateName: string, remove: boolean) { + try { + const current = readTemplates(); + if (remove) { + const next = current.filter((item) => item.name !== templateName); + localStorage.setItem( + storageKey, + JSON.stringify({ version: 1, templates: next }), + ); + setTemplates(next); + } else { + const template = current.find((item) => item.name === templateName); + setTemplates(current); + if (!template) { + setError('This template no longer exists in this browser.'); + return; + } + onLoad(template.prompt); + setOpen(false); + } + setError(''); + } catch { + setError( + remove + ? 'Could not delete the template in this browser. Nothing was deleted.' + : 'Could not load the template from this browser.', + ); + } + } + + return ( + + + + + +
+

Prompt templates

+

+ Saved only in this browser for your account. Text only, not + attachments or settings. +

+

+ Load replaces the current text for review and editing. Send when + ready. +

+
+
+ {templates.length === 0 && ( +

No saved templates.

+ )} + {templates.map((template) => ( +
+ + +
+ ))} +
+
+ + setName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + save(); + } + }} + /> + +
+ {error && ( +

+ {error} +

+ )} +
+
+ ); +}