Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ import {
} from '@roomote/types';

import { cn } from '@/lib/utils';
import { SETTINGS_PATHS } from '@/lib/settings';
import { useAuthorizedUser } from '@/hooks/useUser';

import {
BasicTooltip,
Button,
ChevronDownIcon,
MediaViewerDialog,
MediaViewerImage,
Zap,
} from '@/components/system';
import {
type CollapsibleToggleRenderProps,
Expand All @@ -25,6 +28,7 @@ import {
Attachments,
CollapsibleContent,
Message,
MessageAction,
MessageActions,
MessageContent,
MessagePlainText,
Expand All @@ -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,
Expand Down Expand Up @@ -153,6 +159,7 @@ function getRequestUserInputResponseDisplay(
}

export function AcpTextMessage({ msg }: AcpTextMessageProps) {
const { isAdmin } = useAuthorizedUser();
const [selectedImageIndex, setSelectedImageIndex] = useState<number | null>(
null,
);
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also enables the action for empty or whitespace-only prompts. Image-only user messages render actions because msg.images is present, but the destination rejects ?prompt= at CustomAutomationsSection.tsx:529-532, leaving the user on Automations with no dialog. Require baseContent.trim().length > 0 before showing this action while still passing the untrimmed content through for valid prompts.

? baseContent
: null;
const shouldShowContentActions = isUser
? msg.partial !== true && !taskTool && !linkedReviewResult
: msg.partial !== true &&
Expand Down Expand Up @@ -350,6 +364,16 @@ export function AcpTextMessage({ msg }: AcpTextMessageProps) {
<MessageActions>
<MessageCopyButton content={content} />
<MessageNewTaskButton content={content} />
{automationPrompt !== null ? (
<MessageAction
tooltip="Save as automation"
onClick={() => {
window.location.href = `${SETTINGS_PATHS.automations}?prompt=${encodeURIComponent(automationPrompt)}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This puts the complete task prompt in the initial /automations?prompt=... request, where it can be retained by access logs, proxies, and browser history before replaceState runs. Task prompts can contain credentials or incident data, so the cleanup happens too late. Transfer the value through a client-side, one-time store rather than a URL query parameter.

}}
>
<Zap className="size-4 text-muted-foreground" />
</MessageAction>
) : null}
{!showPersistentTimestamp && (
<MessageTimestamp
ts={msg.ts}
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ export function CustomAutomationsSection() {
const [form, setForm] = useState<CustomAutomationFormState>(EMPTY_FORM);
const [resolvedCron, setResolvedCron] = useState<string | null>(null);
const [scheduleSummary, setScheduleSummary] = useState<string | null>(null);
const promptPrefillHandledRef = useRef(false);

// New destinations default to the shared manager channel, matching where
// the other automations report by default.
Expand Down Expand Up @@ -339,6 +340,34 @@ export function CustomAutomationsSection() {
DESTINATION_OPTIONS.find((option) => option.value === form.targetProvider)
?.label ?? 'Provider';

const initialCreateForm = useMemo<CustomAutomationFormState>(() => {
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)' },
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}}
Expand Down
Loading