From 371d021d490fd613e1e750c6b206ab21547ff119 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:19:21 +0000 Subject: [PATCH] feat: save task prompts as automations --- .../[taskId]/messages/acp/AcpTextMessage.tsx | 24 ++++++ .../__tests__/AcpTextMessage.client.test.tsx | 80 ++++++++++++++++++- ...AutomationsSettings.render.client.test.tsx | 23 ++++++ .../automations/CustomAutomationsSection.tsx | 79 +++++++++++++----- 4 files changed, 183 insertions(+), 23 deletions(-) diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx index 5caebd639..95055ab6d 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx @@ -10,6 +10,8 @@ import { } from '@roomote/types'; import { cn } from '@/lib/utils'; +import { SETTINGS_PATHS } from '@/lib/settings'; +import { useAuthorizedUser } from '@/hooks/useUser'; import { BasicTooltip, @@ -17,6 +19,7 @@ import { ChevronDownIcon, MediaViewerDialog, MediaViewerImage, + Zap, } from '@/components/system'; import { type CollapsibleToggleRenderProps, @@ -25,6 +28,7 @@ import { Attachments, CollapsibleContent, Message, + MessageAction, MessageActions, MessageContent, MessagePlainText, @@ -41,6 +45,8 @@ import type { AcpUiMessage } from './types'; import { ProviderRetryNoticeMessage } from './ProviderRetryNoticeMessage'; import { TerminalProviderErrorMessage } from './TerminalProviderErrorMessage'; +const MAX_AUTOMATION_PREFILL_LENGTH = 5_000; + const UserMessageToggle = ({ isExpanded, toggle, @@ -153,6 +159,7 @@ function getRequestUserInputResponseDisplay( } export function AcpTextMessage({ msg }: AcpTextMessageProps) { + const { isAdmin } = useAuthorizedUser(); const [selectedImageIndex, setSelectedImageIndex] = useState( null, ); @@ -194,6 +201,13 @@ export function AcpTextMessage({ msg }: AcpTextMessageProps) { ) .join('\n\n') : baseContent; + const automationPrompt = + isAdmin && + isUser && + msg.updateType === ACP_ENVELOPE_EVENT_TYPES.UserPrompt && + baseContent.length <= MAX_AUTOMATION_PREFILL_LENGTH + ? baseContent + : null; const shouldShowContentActions = isUser ? msg.partial !== true && !taskTool && !linkedReviewResult : msg.partial !== true && @@ -350,6 +364,16 @@ export function AcpTextMessage({ msg }: AcpTextMessageProps) { + {automationPrompt !== null ? ( + { + window.location.href = `${SETTINGS_PATHS.automations}?prompt=${encodeURIComponent(automationPrompt)}`; + }} + > + + + ) : null} {!showPersistentTimestamp && ( ({ enabled: false, })); +const authState = vi.hoisted(() => ({ isAdmin: true })); + +vi.mock('@/hooks/useUser', () => ({ + useAuthorizedUser: () => ({ isAdmin: authState.isAdmin }), +})); vi.mock('@/components/ai-elements', () => ({ Attachment: ({ @@ -22,6 +27,19 @@ vi.mock('@/components/ai-elements', () => ({
{children}
), Message: ({ children }: { children: ReactNode }) =>
{children}
, + MessageAction: ({ + children, + onClick, + tooltip, + }: { + children: ReactNode; + onClick?: () => void; + tooltip: string; + }) => ( + + ), MessageActions: ({ children }: { children: ReactNode }) => (
{children}
), @@ -100,6 +118,7 @@ vi.mock('@/components/system', () => ({ ScanFace: () => , ScanSearch: () => , Sparkles: () => , + Zap: () => , })); vi.mock('../../../useInternalTranscriptRowsVisible', () => ({ @@ -111,6 +130,8 @@ import { AcpTextMessage } from '../AcpTextMessage'; describe('AcpTextMessage', () => { beforeEach(() => { transcriptVisibilityState.enabled = false; + authState.isAdmin = true; + window.location.href = 'http://localhost:3000'; }); it('shows copy and new task actions for assistant completion text', () => { @@ -135,6 +156,9 @@ describe('AcpTextMessage', () => { expect( screen.getByRole('button', { name: 'new-task:Done!' }), ).toBeVisible(); + expect( + screen.queryByRole('button', { name: 'Save as automation' }), + ).not.toBeInTheDocument(); }); it('passes a permalink anchor id to the timestamp', () => { @@ -305,7 +329,7 @@ describe('AcpTextMessage', () => { ).not.toBeInTheDocument(); }); - it('shows copy and new task actions for user text', () => { + it('shows reuse actions for user text when the user is an admin', () => { render( { expect( screen.getByRole('button', { name: 'new-task:Continue' }), ).toBeVisible(); + expect( + screen.getByRole('button', { name: 'Save as automation' }), + ).toBeVisible(); + }); + + it('hides the automation action from non-admin users', () => { + authState.isAdmin = false; + + render( + , + ); + + expect( + screen.queryByRole('button', { name: 'Save as automation' }), + ).not.toBeInTheDocument(); + }); + + it('preserves and encodes the user prompt when saving it as an automation', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Save as automation' })); + + expect(window.location.href).toBe( + '/automations?prompt=%20%20Review%20errors%20%26%20logs%20%20', + ); }); it('renders user text as plain text instead of markdown', () => { @@ -642,6 +717,9 @@ describe('AcpTextMessage', () => { name: /copy:/, }), ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Save as automation' }), + ).not.toBeInTheDocument(); }); it('renders every question for multi-question request_user_input responses', () => { diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx index 687012f87..dcf6663be 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx @@ -588,6 +588,7 @@ describe('AutomationsSettings', () => { displayName: '#roomote-managers', }; } + window.location.search = ''; window.location.hash = ''; Element.prototype.scrollIntoView = vi.fn(); }); @@ -1135,6 +1136,28 @@ describe('AutomationsSettings', () => { ).not.toBeInTheDocument(); }); + it('opens a new custom automation with a prompt from a task message', async () => { + window.location.pathname = '/automations'; + window.location.search = + '?prompt=%20%20Review%20new%20errors%20%20&source=task'; + const replaceState = vi.spyOn(window.history, 'replaceState'); + + render(); + + expect( + await screen.findByRole('dialog', { name: 'New custom automation' }), + ).toBeInTheDocument(); + expect(screen.getByLabelText('Prompt')).toHaveValue( + ' Review new errors ', + ); + expect(replaceState).toHaveBeenCalledWith( + null, + '', + '/automations?source=task', + ); + closeAutomationDialog(); + }); + it('offers and displays the all-repositories workspace target', async () => { state.customAutomations = [ { diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx index a3308a61c..d122f8c43 100644 --- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx +++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx @@ -302,6 +302,7 @@ export function CustomAutomationsSection() { const [form, setForm] = useState(EMPTY_FORM); const [resolvedCron, setResolvedCron] = useState(null); const [scheduleSummary, setScheduleSummary] = useState(null); + const promptPrefillHandledRef = useRef(false); // New destinations default to the shared manager channel, matching where // the other automations report by default. @@ -339,6 +340,34 @@ export function CustomAutomationsSection() { DESTINATION_OPTIONS.find((option) => option.value === form.targetProvider) ?.label ?? 'Provider'; + const initialCreateForm = useMemo(() => { + const managerProvider = + managerSlackChannelId && capabilities?.slackConnected + ? 'slack' + : managerDiscordChannelId && capabilities?.discordConnected + ? 'discord' + : null; + const targetProvider = + managerProvider ?? connectedDestinationOptions[0]?.value ?? 'none'; + + return { + ...EMPTY_FORM, + targetProvider, + targetChannelId: + targetProvider === 'slack' + ? managerSlackChannelId + : targetProvider === 'discord' + ? managerDiscordChannelId + : '', + }; + }, [ + capabilities?.discordConnected, + capabilities?.slackConnected, + connectedDestinationOptions, + managerDiscordChannelId, + managerSlackChannelId, + ]); + const environmentOptions = useMemo( () => [ { id: FAST_EXECUTION, name: 'Fast (no sandbox)' }, @@ -491,6 +520,33 @@ export function CustomAutomationsSection() { deleteMutation.isPending || toggleMutation.isPending; + useEffect(() => { + if (promptPrefillHandledRef.current || !capabilitiesLoaded || atCap) { + return; + } + + const searchParams = new URLSearchParams(window.location.search); + const prompt = searchParams.get('prompt'); + if (!prompt?.trim()) { + return; + } + + promptPrefillHandledRef.current = true; + setIsCreating(true); + setEditingId(null); + setForm({ ...initialCreateForm, prompt: prompt.slice(0, 8_000) }); + setResolvedCron(null); + setScheduleSummary(null); + + searchParams.delete('prompt'); + const query = searchParams.toString(); + window.history.replaceState( + null, + '', + `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`, + ); + }, [atCap, capabilitiesLoaded, initialCreateForm]); + const closeEditor = () => { setIsCreating(false); setEditingId(null); @@ -1028,30 +1084,9 @@ export function CustomAutomationsSection() { size="sm" disabled={busy || atCap || !capabilitiesLoaded} onClick={() => { - const managerProvider = - managerSlackChannelId && - settingsQuery.data?.capabilities.slackConnected - ? 'slack' - : managerDiscordChannelId && - settingsQuery.data?.capabilities.discordConnected - ? 'discord' - : null; - const targetProvider = - managerProvider ?? - connectedDestinationOptions[0]?.value ?? - 'none'; setIsCreating(true); setEditingId(null); - setForm({ - ...EMPTY_FORM, - targetProvider, - targetChannelId: - targetProvider === 'slack' - ? managerSlackChannelId - : targetProvider === 'discord' - ? managerDiscordChannelId - : '', - }); + setForm(initialCreateForm); setResolvedCron(null); setScheduleSummary(null); }}