From a54c27f00d8aca6f35759c378375f07f6fd4fc5c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:05:48 -0700 Subject: [PATCH 01/39] Restructure automation editor dialog into three-column layout (#14803) * Restructure automation editor dialog into three-column layout - Separate prompt editing from settings configuration - Add Monaco editor for prompt with find widget support - Extract settings into right sidebar for better organization - Move automation name into prompt section for context - Simplify header and footer to focus on key actions - Settings controls now smoothly collapse when switching between Orca and Hermes targets * Fix React Doctor leak on automation prompt Escape listener. Move addEventListener into a helper that returns cleanup so the changed-code quality gate can see the subscription is released. * Fix stale ref closures in automation prompt editor - Move `onDismissRef.current` update into useLayoutEffect with `[onDismiss]` dependency to prevent stale closures in event listeners - Move `contentRef.current` update into the layout effect that syncs it, ensuring editor has current value when effects reference it --- .../automations/AutomationEditorDialog.tsx | 113 +++---- .../AutomationEditorDialogFooter.tsx | 283 ++---------------- .../AutomationEditorDialogHeader.tsx | 186 ++++++------ .../AutomationEditorPromptEditor.test.tsx | 194 ++++++++++++ .../AutomationEditorPromptEditor.tsx | 167 +++++++++++ .../AutomationEditorPromptSection.tsx | 137 +++++---- .../AutomationEditorSettingsSidebar.tsx | 221 ++++++++++++++ .../AutomationMissedRunGraceField.tsx | 3 +- .../automations/AutomationPrecheckFields.tsx | 160 +++++----- .../automations/AutomationSchedulePicker.tsx | 214 +++++-------- .../automations/AutomationSessionField.tsx | 10 +- .../automations/AutomationWorkspaceField.tsx | 128 ++++++++ .../automation-editor-prompt-options.test.ts | 23 ++ .../automation-editor-prompt-options.ts | 43 +++ .../automations/automation-page-parts.tsx | 9 +- .../editor/monaco-find-widget.test.ts | 141 +++++++++ .../components/editor/monaco-find-widget.ts | 53 ++++ src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + 22 files changed, 1411 insertions(+), 685 deletions(-) create mode 100644 src/renderer/src/components/automations/AutomationEditorPromptEditor.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx create mode 100644 src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx create mode 100644 src/renderer/src/components/automations/AutomationWorkspaceField.tsx create mode 100644 src/renderer/src/components/automations/automation-editor-prompt-options.test.ts create mode 100644 src/renderer/src/components/automations/automation-editor-prompt-options.ts create mode 100644 src/renderer/src/components/editor/monaco-find-widget.test.ts create mode 100644 src/renderer/src/components/editor/monaco-find-widget.ts diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index 569d1a8a2ea..01a3f30a0d0 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -13,22 +13,22 @@ import type { Repo } from '../../../../shared/repo-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { SetupDecision } from '../../../../shared/worktree/create-types' import type { Worktree } from '../../../../shared/worktree/types' -import { - isValidAutomationCronSchedule, - isValidAutomationSchedule -} from '../../../../shared/automation-schedules' -import { Field } from './automation-page-parts' +import { closeUnfocusedMonacoFindOrPreventDialogDismiss } from '@/components/editor/monaco-find-widget' import { AutomationEditorDialogFooter } from './AutomationEditorDialogFooter' import { AutomationEditorDialogHeader } from './AutomationEditorDialogHeader' +import { getAutomationPromptEditorRoot } from './AutomationEditorPromptEditor' import { AutomationEditorPromptSection } from './AutomationEditorPromptSection' -import { AutomationSchedulePicker } from './AutomationSchedulePicker' +import { AutomationEditorSettingsSidebar } from './AutomationEditorSettingsSidebar' import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' -import { translate } from '@/i18n/i18n' -const PICKER_TRIGGER_CLASS = +export const AUTOMATION_EDITOR_PICKER_TRIGGER_CLASS = 'border-input bg-input/30 shadow-xs hover:bg-accent/60 dark:bg-input/30 dark:hover:bg-input/50' -const MODE_TOGGLE_ITEM_CLASS = - 'w-full border-input bg-input/30 shadow-xs hover:bg-accent/60 data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:hover:bg-primary/90 dark:bg-input/30 dark:data-[state=on]:bg-primary dark:data-[state=on]:text-primary-foreground dark:data-[state=on]:hover:bg-primary/90' + +export const AUTOMATION_EDITOR_SEGMENTED_GROUP_CLASS = + 'grid w-full grid-cols-2 rounded-md bg-muted p-0.5' + +export const AUTOMATION_EDITOR_SEGMENTED_ITEM_CLASS = + 'h-7 rounded-sm border-0 bg-transparent shadow-none hover:bg-card/70 data-[state=on]:bg-card data-[state=on]:text-card-foreground data-[state=on]:shadow-xs' export type AutomationDraft = { name: string @@ -102,6 +102,7 @@ export function AutomationEditorDialog({ onSave }: AutomationEditorDialogProps): React.JSX.Element { const [templateOpen, setTemplateOpen] = React.useState(false) + const dialogContentRef = React.useRef(null) const isHermesTarget = createTarget === 'hermes' const isCreateMode = !isEditing && !isEditingExternal const isHermesCreate = isCreateMode && isHermesTarget @@ -116,28 +117,33 @@ export function AutomationEditorDialog({ (agent) => enabledIds.has(agent.id) || agent.id === draft.agentId ) }, [draft.agentId, settings?.disabledTuiAgents]) - const scheduleField = ( - - - - ) return ( { event.preventDefault() }} + onEscapeKeyDown={(event) => { + // Why: Radix listens on document, so currentTarget is not the + // dialog. Use the content ref to scope find to this prompt editor. + const dialog = + dialogContentRef.current ?? + (event.target instanceof Element + ? event.target.closest('[data-slot="dialog-content"]') + : null) + if ( + closeUnfocusedMonacoFindOrPreventDialogDismiss({ + root: getAutomationPromptEditorRoot(dialog), + eventTarget: event.target + }) + ) { + event.preventDefault() + } + }} > onDraftChange((current) => ({ ...current, name }))} onTemplateOpenChange={setTemplateOpen} onApplyTemplate={(template) => { onApplyTemplate(template) @@ -159,36 +163,41 @@ export function AutomationEditorDialog({ }} /> - +
+ onOpenChange(false)} + /> + +
0} onOpenChange={onOpenChange} onSave={onSave} /> diff --git a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx index 2c68beee9b3..234a71efae3 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx @@ -1,50 +1,14 @@ -import React from 'react' -import { Info, Plus } from 'lucide-react' +import { Sparkles } from 'lucide-react' import { Button } from '@/components/ui/button' -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import AgentCombobox from '@/components/agent/AgentCombobox' -import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import type { AutomationWorkspaceMode } from '../../../../shared/automations-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' -import type { ProjectHostSetup } from '../../../../shared/project-types' -import type { Repo } from '../../../../shared/repo-types' -import type { Worktree } from '../../../../shared/worktree/types' -import type { AgentCatalogEntry } from '@/lib/agent-catalog' -import { Field } from './automation-page-parts' -import { AutomationMissedRunGraceField } from './AutomationMissedRunGraceField' -import { AutomationSessionField } from './AutomationSessionField' -import { AutomationSetupDecisionField } from './AutomationSetupDecisionField' -import { CreateFromPicker } from './CreateFromPicker' -import { WorkspaceCombobox } from './WorkspaceCombobox' -import AutomationProjectCombobox from './AutomationProjectCombobox' -import type { AutomationDraft } from './AutomationEditorDialog' type AutomationEditorDialogFooterProps = { isEditing: boolean isEditingExternal: boolean - isHermesTarget: boolean isHermesCreate: boolean isSaving: boolean canSave: boolean - repos: readonly Repo[] - projectHostSetups: readonly ProjectHostSetup[] - automationYamlHooksByRepoKey: Record - getAutomationHooksCacheKey: (repoId: string) => string - repoMap: Map - worktrees: Worktree[] - settings: GlobalSettings | null - draft: AutomationDraft - visibleAgents: AgentCatalogEntry[] - scheduleField: React.ReactNode - pickerTriggerClassName: string - modeToggleItemClassName: string - onProjectChange: (projectId: string) => void - getRepoHostLabel?: (repo: Repo) => string | null | undefined - onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void - onSetupDecisionTouched: () => void + hasProjects: boolean onOpenChange: (open: boolean) => void onSave: () => void } @@ -52,238 +16,37 @@ type AutomationEditorDialogFooterProps = { export function AutomationEditorDialogFooter({ isEditing, isEditingExternal, - isHermesTarget, isHermesCreate, isSaving, canSave, - repos, - projectHostSetups, - automationYamlHooksByRepoKey, - getAutomationHooksCacheKey, - repoMap, - worktrees, - settings, - draft, - visibleAgents, - scheduleField, - pickerTriggerClassName, - modeToggleItemClassName, - onProjectChange, - getRepoHostLabel, - onDraftChange, - onSetupDecisionTouched, + hasProjects, onOpenChange, onSave }: AutomationEditorDialogFooterProps): React.JSX.Element { - return ( -
-
- - - - - {translate( - 'auto.components.automations.AutomationEditorDialog.b28b140eaf', - 'Workspace' - )} - - - - - - {translate( - 'auto.components.automations.AutomationEditorDialog.6f9610e667', - 'Worktree runs in the selected workspace. New run creates a fresh workspace from the selected branch each time.' - )} - - - - } - className={isHermesTarget ? undefined : 'sm:col-span-2 lg:col-span-3'} - > - {isHermesTarget ? ( - - onDraftChange((current) => ({ ...current, workspaceId })) - } - /> - ) : ( -
- - workspaceMode && - onDraftChange((current) => ({ - ...current, - workspaceMode: workspaceMode as AutomationWorkspaceMode, - reuseSession: workspaceMode === 'existing' ? current.reuseSession : false - })) - } - variant="outline" - size="sm" - className="grid w-full grid-cols-2" - > - - {translate( - 'auto.components.automations.AutomationEditorDialog.a2e688226d', - 'Worktree' - )} - - - {translate( - 'auto.components.automations.AutomationEditorDialog.6ff66f9012', - 'New run' - )} - - - {draft.workspaceMode === 'existing' ? ( - - onDraftChange((current) => ({ ...current, workspaceId })) - } - /> - ) : ( - - onDraftChange((current) => ({ ...current, baseBranch })) - } - /> - )} -
- )} -
- {isHermesTarget ? scheduleField : null} -
+ const saveLabel = + isEditing || isEditingExternal + ? translate('auto.components.automations.AutomationEditorDialog.777548c2d6', 'Save Changes') + : isSaving || isHermesCreate + ? translate('auto.components.automations.AutomationEditorDialog.a9d9dccf77', 'Save') + : translate('auto.components.automations.AutomationEditorDialog.e46c1aa9ad', 'Create') - {/* Why: Hermes uses one compact footer row, while Orca adds agent, - session, schedule, and missed-run controls. Animate that row so - switching the target changes the dialog height smoothly. */} -
-
-
- - - agentId && onDraftChange((current) => ({ ...current, agentId })) - } - defaultAgent={settings?.defaultTuiAgent ?? null} - triggerClassName={`h-9 w-full min-w-0 ${pickerTriggerClassName}`} - allowNarrowTrigger - /> - - - {isHermesTarget ? null : scheduleField} - -
- {/* Why: a full-width row below the columned controls — the setup - switch is a per-automation preference, not a peer of the compact - column pickers, so it reads cleaner spanning the dialog. */} - -
-
-
+ return ( +
+

+ + + {translate( + 'auto.components.automations.AutomationEditorDialog.e8c2a14f70', + 'Once saved, runs automatically until paused.' + )} + +

+
-
diff --git a/src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx b/src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx index b1712f5250c..a36e4ab0b5b 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx @@ -1,7 +1,6 @@ import { Sparkles } from 'lucide-react' import { Button } from '@/components/ui/button' import { DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import type { AutomationCreateTarget } from './AutomationEditorDialog' @@ -14,13 +13,11 @@ type AutomationEditorDialogHeaderProps = { isHermesCreate: boolean isCreateMode: boolean createTarget: AutomationCreateTarget - draftName: string templateOpen: boolean templates: AutomationTemplate[] - modeToggleItemClassName: string - pickerTriggerClassName: string + segmentedGroupClassName: string + segmentedItemClassName: string onCreateTargetChange: (target: AutomationCreateTarget) => void - onDraftNameChange: (name: string) => void onTemplateOpenChange: (open: boolean) => void onApplyTemplate: (template: AutomationTemplate) => void } @@ -47,116 +44,103 @@ function AutomationTemplateCard({ ) } +function getEditorTitle(args: { + isEditing: boolean + isEditingExternal: boolean + isHermesCreate: boolean +}): string { + if (args.isEditing) { + return translate( + 'auto.components.automations.AutomationEditorDialogHeader.17086b48ee', + 'Edit automation' + ) + } + if (args.isEditingExternal) { + return translate( + 'auto.components.automations.AutomationEditorDialogHeader.03142e7721', + 'Edit Hermes automation' + ) + } + if (args.isHermesCreate) { + return translate( + 'auto.components.automations.AutomationEditorDialogHeader.0a75e5e2fa', + 'Create Hermes automation' + ) + } + return translate( + 'auto.components.automations.AutomationEditorDialogHeader.4133d33862', + 'Create automation' + ) +} + export function AutomationEditorDialogHeader({ isEditing, isEditingExternal, isHermesCreate, isCreateMode, createTarget, - draftName, templateOpen, templates, - modeToggleItemClassName, - pickerTriggerClassName, + segmentedGroupClassName, + segmentedItemClassName, onCreateTargetChange, - onDraftNameChange, onTemplateOpenChange, onApplyTemplate }: AutomationEditorDialogHeaderProps): React.JSX.Element { + const title = getEditorTitle({ isEditing, isEditingExternal, isHermesCreate }) + return ( - -
-
- - {isEditing - ? translate( - 'auto.components.automations.AutomationEditorDialogHeader.17086b48ee', - 'Edit automation' - ) - : isEditingExternal - ? translate( - 'auto.components.automations.AutomationEditorDialogHeader.03142e7721', - 'Edit Hermes automation' - ) - : isHermesCreate - ? translate( - 'auto.components.automations.AutomationEditorDialogHeader.0a75e5e2fa', - 'Create Hermes automation' - ) - : translate( - 'auto.components.automations.AutomationEditorDialogHeader.4133d33862', - 'Create automation' - )} - - onDraftNameChange(event.target.value)} - /> + + {title} + {isCreateMode ? ( +
+ + value && onCreateTargetChange(value as AutomationCreateTarget) + } + size="sm" + className={segmentedGroupClassName} + > + + {translate( + 'auto.components.automations.AutomationEditorDialogHeader.6f309eef8d', + 'Orca' + )} + + + {translate( + 'auto.components.automations.AutomationEditorDialogHeader.7e35393632', + 'Hermes' + )} + + + + + + + +
+ {templates.map((template) => ( + onApplyTemplate(template)} + /> + ))} +
+
+
- {isCreateMode ? ( -
- - value && onCreateTargetChange(value as AutomationCreateTarget) - } - variant="outline" - size="sm" - className="grid grid-cols-2" - > - - {translate( - 'auto.components.automations.AutomationEditorDialogHeader.6f309eef8d', - 'Orca' - )} - - - {translate( - 'auto.components.automations.AutomationEditorDialogHeader.7e35393632', - 'Hermes' - )} - - - - - - - -
- {templates.map((template) => ( - onApplyTemplate(template)} - /> - ))} -
-
-
-
- ) : null} -
+ ) : null} ) } diff --git a/src/renderer/src/components/automations/AutomationEditorPromptEditor.test.tsx b/src/renderer/src/components/automations/AutomationEditorPromptEditor.test.tsx new file mode 100644 index 00000000000..579d37d4e57 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationEditorPromptEditor.test.tsx @@ -0,0 +1,194 @@ +// @vitest-environment happy-dom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const editorProps = vi.hoisted(() => ({ current: null as Record | null })) +const installFindShortcut = vi.hoisted(() => vi.fn(() => vi.fn())) +const syncContentOnMount = vi.hoisted(() => vi.fn(() => false)) +const syncContentUpdate = vi.hoisted(() => vi.fn()) + +vi.mock('@monaco-editor/react', () => ({ + default: (props: Record) => { + editorProps.current = props + return
+ }, + loader: { config: vi.fn() } +})) +vi.mock('@/lib/monaco-setup', () => ({ monaco: {} })) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + settings: { theme: 'dark', terminalFontSize: 13, terminalFontFamily: 'monospace' }, + editorFontZoomLevel: 0 + }) +})) +vi.mock('@/components/editor/editor-shortcuts', () => ({ + installMonacoEditorFindShortcut: installFindShortcut +})) +vi.mock('@/components/editor/monaco-content-sync', () => ({ + syncContentOnMount, + syncContentUpdate +})) + +import { closeUnfocusedMonacoFindOrPreventDialogDismiss } from '@/components/editor/monaco-find-widget' +import { + AUTOMATION_PROMPT_EDITOR_SLOT, + AutomationEditorPromptEditor, + getAutomationPromptEditorRoot +} from './AutomationEditorPromptEditor' + +afterEach(() => { + cleanup() + editorProps.current = null + installFindShortcut.mockClear() + syncContentOnMount.mockClear() + syncContentUpdate.mockClear() +}) + +describe('AutomationEditorPromptEditor', () => { + it('owns content through defaultValue so Monaco keeps undo history', () => { + render( + + ) + + expect(editorProps.current?.defaultValue).toBe('weekly audit') + expect(editorProps.current).not.toHaveProperty('value') + expect(editorProps.current?.defaultLanguage).toBe('plaintext') + expect( + (editorProps.current?.options as { placeholder?: string } | undefined)?.placeholder + ).toBe('Prompt placeholder') + }) + + it('installs find and syncs an external rewrite after mount', () => { + const editorInstance = { + getContainerDomNode: () => document.createElement('div'), + onDidDispose: vi.fn() + } + const { rerender } = render( + + ) + + const onMount = editorProps.current?.onMount as (editor: typeof editorInstance) => void + onMount(editorInstance) + + expect(installFindShortcut).toHaveBeenCalledWith(editorInstance) + expect(syncContentOnMount).toHaveBeenCalledWith(editorInstance, 'original') + + rerender( + + ) + + expect(syncContentUpdate).toHaveBeenCalledWith(editorInstance, 'from template') + }) + + it('dismisses on Escape only when find is closed', () => { + const onDismiss = vi.fn() + const editorDom = document.createElement('div') + const editorInstance = { + getContainerDomNode: () => editorDom, + onDidDispose: vi.fn() + } + render( + + ) + const onMount = editorProps.current?.onMount as (editor: typeof editorInstance) => void + onMount(editorInstance) + + const findWidget = document.createElement('div') + findWidget.className = 'find-widget' + editorDom.append(findWidget) + editorDom.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + expect(onDismiss).not.toHaveBeenCalled() + + findWidget.setAttribute('aria-hidden', 'true') + editorDom.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) + + it('does not render a custom placeholder overlay on an empty prompt', () => { + const { container } = render( + + ) + + expect(container.textContent).not.toContain('Prompt placeholder') + expect( + (editorProps.current?.options as { placeholder?: string } | undefined)?.placeholder + ).toBe('Prompt placeholder') + }) + + it('scopes find-widget lookups to the prompt editor slot, not the document', () => { + const dialog = document.createElement('div') + const editorRoot = document.createElement('div') + editorRoot.setAttribute('data-slot', AUTOMATION_PROMPT_EDITOR_SLOT) + const nameField = document.createElement('input') + const background = document.createElement('div') + const backgroundFind = document.createElement('div') + backgroundFind.className = 'find-widget' + const closeButton = document.createElement('button') + closeButton.className = 'button codicon-widget-close' + const onBackgroundClose = vi.fn() + closeButton.addEventListener('click', onBackgroundClose) + backgroundFind.append(closeButton) + background.append(backgroundFind) + dialog.append(editorRoot, nameField) + document.body.append(dialog, background) + + expect(getAutomationPromptEditorRoot(dialog)).toBe(editorRoot) + // Why: Radix's Escape listener fires on document, so currentTarget is + // never the dialog — probing document would see background editors. + expect(getAutomationPromptEditorRoot(document)).toBeNull() + expect(getAutomationPromptEditorRoot(null)).toBeNull() + expect( + closeUnfocusedMonacoFindOrPreventDialogDismiss({ + root: getAutomationPromptEditorRoot(dialog), + eventTarget: nameField + }) + ).toBe(false) + expect(onBackgroundClose).not.toHaveBeenCalled() + + dialog.remove() + background.remove() + }) + + it('forwards typed edits to the draft', () => { + const onChange = vi.fn() + render( + + ) + + const handleChange = editorProps.current?.onChange as (value: string) => void + handleChange('typed prompt') + expect(onChange).toHaveBeenCalledWith('typed prompt') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx b/src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx new file mode 100644 index 00000000000..69de2374d27 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx @@ -0,0 +1,167 @@ +import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react' +import Editor, { type OnMount } from '@monaco-editor/react' +import type { editor } from 'monaco-editor' +import { installMonacoEditorFindShortcut } from '@/components/editor/editor-shortcuts' +import { syncContentOnMount, syncContentUpdate } from '@/components/editor/monaco-content-sync' +import { isMonacoFindWidgetOpen } from '@/components/editor/monaco-find-widget' +import { computeEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom' +import { resolveDocumentTheme } from '@/lib/document-theme' +import '@/lib/monaco-setup' +import { useAppStore } from '@/store' +import { cn } from '@/lib/utils' +import { buildAutomationPromptEditorOptions } from './automation-editor-prompt-options' + +export const AUTOMATION_PROMPT_EDITOR_SLOT = 'automation-prompt-editor' + +export function getAutomationPromptEditorRoot( + dialog: EventTarget | null | undefined +): Element | null { + return dialog instanceof Element + ? dialog.querySelector(`[data-slot="${AUTOMATION_PROMPT_EDITOR_SLOT}"]`) + : null +} + +function installPromptEditorEscapeDismiss( + target: HTMLElement, + onDismissRef: { current?: () => void } +): () => void { + const handleEscape = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || event.repeat) { + return + } + if (isMonacoFindWidgetOpen(target)) { + return + } + // Why: Monaco swallows Escape even with find closed; the wrapping + // dialog still needs the same dismiss path a textarea used to have. + if (!onDismissRef.current) { + return + } + event.preventDefault() + event.stopPropagation() + onDismissRef.current() + } + target.addEventListener('keydown', handleEscape, true) + return () => target.removeEventListener('keydown', handleEscape, true) +} + +type AutomationEditorPromptEditorProps = { + value: string + placeholder: string + ariaLabel: string + onChange: (value: string) => void + onDismiss?: () => void +} + +export function AutomationEditorPromptEditor({ + value, + placeholder, + ariaLabel, + onChange, + onDismiss +}: AutomationEditorPromptEditorProps): React.JSX.Element { + const settings = useAppStore((state) => state.settings) + const editorFontZoomLevel = useAppStore((state) => state.editorFontZoomLevel) + const editorRef = useRef(null) + const contentRef = useRef(value) + const lastSyncedContentRef = useRef(value) + const isApplyingProgrammaticContentRef = useRef(false) + const onDismissRef = useRef(onDismiss) + + useLayoutEffect(() => { + onDismissRef.current = onDismiss + }, [onDismiss]) + + const fontSize = computeEditorFontSize(settings?.terminalFontSize ?? 13, editorFontZoomLevel) + const fontFamily = resolveEditorFontFamily(settings) + const isDark = resolveDocumentTheme(settings?.theme ?? 'system') + const options = useMemo( + () => + buildAutomationPromptEditorOptions({ + ariaLabel, + fontFamily, + fontSize, + placeholder + }), + [ariaLabel, fontFamily, fontSize, placeholder] + ) + + const handleChange = useCallback( + (nextValue: string | undefined) => { + if (isApplyingProgrammaticContentRef.current) { + return + } + const next = nextValue ?? '' + lastSyncedContentRef.current = next + onChange(next) + }, + [onChange] + ) + + const handleMount = useCallback((editorInstance) => { + editorRef.current = editorInstance + const editorDomNode = editorInstance.getContainerDomNode() + const cleanupFindShortcut = installMonacoEditorFindShortcut(editorInstance) + const cleanupEscapeDismiss = installPromptEditorEscapeDismiss(editorDomNode, onDismissRef) + isApplyingProgrammaticContentRef.current = true + try { + if (syncContentOnMount(editorInstance, contentRef.current)) { + lastSyncedContentRef.current = contentRef.current + } + } finally { + isApplyingProgrammaticContentRef.current = false + } + editorInstance.onDidDispose(() => { + cleanupFindShortcut() + cleanupEscapeDismiss() + if (editorRef.current === editorInstance) { + editorRef.current = null + } + }) + }, []) + + // Why: templates rewrite the draft while this editor stays mounted; push an + // undoable model edit instead of setValue so Cmd+Z can revert the apply. + useLayoutEffect(() => { + contentRef.current = value + const mountedEditor = editorRef.current + if (!mountedEditor || lastSyncedContentRef.current === value) { + return + } + isApplyingProgrammaticContentRef.current = true + try { + syncContentUpdate(mountedEditor, value) + lastSyncedContentRef.current = value + } finally { + isApplyingProgrammaticContentRef.current = false + } + }, [value]) + + return ( +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationEditorPromptSection.tsx b/src/renderer/src/components/automations/AutomationEditorPromptSection.tsx index e159c264ba5..df988eb5039 100644 --- a/src/renderer/src/components/automations/AutomationEditorPromptSection.tsx +++ b/src/renderer/src/components/automations/AutomationEditorPromptSection.tsx @@ -1,83 +1,104 @@ import React from 'react' -import { cn } from '@/lib/utils' +import { Pencil } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' -import { Field } from './automation-page-parts' -import { AutomationPrecheckFields } from './AutomationPrecheckFields' import type { AutomationDraft } from './AutomationEditorDialog' +import { AutomationEditorPromptEditor } from './AutomationEditorPromptEditor' type AutomationEditorPromptSectionProps = { draft: AutomationDraft - isHermesCreate: boolean - pickerTriggerClassName: string onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void + onDismiss: () => void } export function AutomationEditorPromptSection({ draft, - isHermesCreate, - pickerTriggerClassName, - onDraftChange + onDraftChange, + onDismiss }: AutomationEditorPromptSectionProps): React.JSX.Element { + const titleRef = React.useRef(null) + const namePlaceholder = translate( + 'auto.components.automations.AutomationEditorDialogHeader.1d9826933e', + 'Weekday repo audit' + ) + const nameLabel = translate( + 'auto.components.automations.AutomationEditorDialogHeader.58f56b73d9', + 'Automation name' + ) + const editNameLabel = translate( + 'auto.components.automations.AutomationEditorPromptSection.a7c3e91b04', + 'Edit name' + ) + return ( -
+
{draft.scheduleWarning ? ( -
+
{draft.scheduleWarning}
) : null} - +