diff --git a/application/single_app/config.py b/application/single_app/config.py index 910c48a63..c5e814cb4 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -100,7 +100,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.212" +VERSION = "0.261.213" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/v2_ui/src/components/chat/AssistThread.tsx b/application/v2_ui/src/components/chat/AssistThread.tsx index db2c8ff61..df7694d16 100644 --- a/application/v2_ui/src/components/chat/AssistThread.tsx +++ b/application/v2_ui/src/components/chat/AssistThread.tsx @@ -9,7 +9,7 @@ import { useEffect, useId, useRef, useState, type ReactNode } from 'react'; import { clsx } from 'clsx'; import { FileText, FolderOpen, Loader2, PenLine, RotateCcw, Send, Tag as TagIcon, X } from 'lucide-react'; import { ComposerEditor } from './ComposerEditor'; -import type { AssistExchange, AssistThreadController } from '../../lib/assistThread'; +import { CANCELLED_MESSAGE, type AssistExchange, type AssistThreadController } from '../../lib/assistThread'; /** A `#` chip a turn was sent with, shown read-only in the thread. */ export interface AssistReferenceChip { @@ -48,6 +48,11 @@ export interface AssistThreadProps { logFooter?: ReactNode; /** What the assistant said in an exchange the server does not store. */ renderReply?: (exchange: AssistExchange) => ReactNode; + /** + * Shown for a request the user cancelled, in place of the default note that the change may + * still be applied. Only for an editor whose server writes nothing, so a cancel is safe. + */ + cancelledMessage?: string; sendLabel: ReactNode; busyLabel?: ReactNode; sendAriaLabel?: string; @@ -67,6 +72,8 @@ export interface AssistThreadProps { * the references with its request, and the server must authorise them for the acting user. */ allowContext?: boolean; + /** With `allowContext`, offer `#` documents only: no tags and no workspaces. Off by default. */ + contextDocumentsOnly?: boolean; counterHint?: string; density?: 'compact' | 'comfortable'; className?: string; @@ -125,6 +132,7 @@ export function AssistThread({ logHeader, logFooter, renderReply, + cancelledMessage, sendLabel, busyLabel, sendAriaLabel, @@ -135,6 +143,7 @@ export function AssistThread({ describedBy, composerNote, allowContext = false, + contextDocumentsOnly = false, counterHint, density = 'compact', className, @@ -208,15 +217,19 @@ export function AssistThread({

) : exchange.status === 'done' ? ( -

- {renderReply ? renderReply(exchange) : exchange.reply} -

+ renderReply ? ( +
{renderReply(exchange)}
+ ) : ( +

{exchange.reply}

+ ) ) : ( <> {exchange.status === 'failed' ? (

{exchange.error}

) : ( -

{exchange.error}

+

+ {cancelledMessage && exchange.error === CANCELLED_MESSAGE ? cancelledMessage : exchange.error} +

)}
+ ) : null} + + ))} + +
+ ) : null} + {record.contextDocuments.length ? ( +

+ {`Read as context: ${record.contextDocuments.join(', ')}`} +

+ ) : null} + {record.warnings.length ? ( + + ) : null} + {undoable ? ( + + ) : null} + {record.undo ? ( +
+ +
+ ) : null} + + ); +} + +export function WorkflowAskAiTab({ assist, inputId, onReload }: { + assist: WorkflowAssist; + inputId: string; + /** Reload the saved workflow, discarding the draft. Offered after the workflow changed elsewhere. */ + onReload?: () => Promise; +}) { + const baseId = useId(); + const focusId = `${baseId}-focus`; + const reloadButtonId = `${baseId}-reload`; + const keepEditingId = `${baseId}-keep`; + const [reloadStep, setReloadStep] = useState<'idle' | 'confirm' | 'loading'>('idle'); + const [reloadFailed, setReloadFailed] = useState(false); + const { thread, focus } = assist; + const quickDisabled = assist.busy || Boolean(assist.pending); + + const focusById = (id: string) => requestAnimationFrame(() => document.getElementById(id)?.focus()); + const reload = async () => { + if (!onReload) return; + setReloadStep('loading'); + setReloadFailed(false); + try { + await onReload(); + } catch { + setReloadFailed(true); + setReloadStep('idle'); + focusById(reloadButtonId); + } + }; + + const renderReply = (exchange: AssistExchange) => { + const record = assist.turns[exchange.id]; + if (!record || record.threadKey !== thread.key) { + return

{exchange.reply}

; + } + return ; + }; + + return ( +
+

+ Ask AI edits this draft only. Nothing is saved until you save the workflow, and you can undo each change. +

+ {assist.reloadOffered && onReload ? ( +
+

+ {reloadStep === 'confirm' + ? 'Reloading discards your unsaved changes to this workflow.' + : 'The saved workflow changed after you opened it. Reload it to keep using Ask AI.'} +

+
+ {reloadStep === 'confirm' ? <> + void reload()}> + Discard and reload + + { + setReloadStep('idle'); + focusById(reloadButtonId); + }}> + Keep editing + + : ( + { + setReloadStep('confirm'); + focusById(keepEditingId); + }}> + {reloadStep === 'loading' ? 'Reloading…' : 'Reload workflow'} + + )} +
+ {reloadFailed ?

Couldn't reload the workflow. Try again.

: null} +
+ ) : null} +
+ {WORKFLOW_ASSIST_QUICK_ACTIONS.map((action) => ( + + ))} +
+ + Ask for a change, such as “run this at 7 AM on weekdays and only alert me when something is urgent”, + or ask a question about this workflow. Type # to use one of your documents. +

+ )} + composerClassName="mt-2" + composerNote={<> + {focus ? ( +
+ + + +
+ ) : null} + {assist.waitSeconds > 0 ? ( +

+ {`You can send again in ${assist.waitSeconds} s.`} +

+ ) : null} + } + /> +
+ ); +} + +/** The editor pane's notice while a turn runs: the editor is locked until the answer is in. */ +export function WorkflowAssistLockBanner({ pending, onCancel }: { + pending: AssistExchange; + onCancel: () => void; +}) { + return ( +
+
+ ); +} diff --git a/application/v2_ui/src/components/workflows/WorkflowChangeTracking.tsx b/application/v2_ui/src/components/workflows/WorkflowChangeTracking.tsx index a1cad3273..464444655 100644 --- a/application/v2_ui/src/components/workflows/WorkflowChangeTracking.tsx +++ b/application/v2_ui/src/components/workflows/WorkflowChangeTracking.tsx @@ -241,9 +241,10 @@ export function WorkflowChangedField({ changeKey, className, children }: { const result = tracking.session.revertChange([fieldChange.key]); if (result.status === 'applied') refocusAfterRevert(rootRef); }; + // The field key is always there, so a jump made before the deferred highlight renders still finds the field. return (
{children} {stamp ? ( @@ -548,18 +549,28 @@ export interface WorkflowSidePanelTab { readonly id: string; readonly label: string; readonly content: ReactNode; + /** Replaces the panel's default scrolling and padding, for a tab that lays out its own. */ + readonly panelClassName?: string; } /** * The editor's side panel. It is a tab list so the Changes tab can gain siblings, such as Ask AI; - * tabs follow the APG pattern with automatic activation. + * tabs follow the APG pattern with automatic activation. Pass `selected` and `onSelect` to choose + * the tab from outside, such as a button that opens the panel on Ask AI. */ -export function WorkflowEditorSidePanel({ id, tabs, className }: { +export function WorkflowEditorSidePanel({ id, tabs, className, selected: selectedTab, onSelect }: { id: string; tabs: readonly WorkflowSidePanelTab[]; className?: string; + selected?: string; + onSelect?: (id: string) => void; }) { - const [selected, setSelected] = useState(tabs[0]?.id ?? ''); + const [ownSelected, setOwnSelected] = useState(tabs[0]?.id ?? ''); + const selected = selectedTab ?? ownSelected; + const setSelected = (tabId: string) => { + if (selectedTab === undefined) setOwnSelected(tabId); + onSelect?.(tabId); + }; const baseId = useId(); const tabRefs = useRef(new Map()); const active = tabs.find((tab) => tab.id === selected) ?? tabs[0]; @@ -601,7 +612,7 @@ export function WorkflowEditorSidePanel({ id, tabs, className }: {
{active ? (
+ className={clsx('min-h-0 flex-1', active.panelClassName ?? 'overflow-y-auto p-3')}> {active.content}
) : null} diff --git a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx index c139e37b8..c2556d4c8 100644 --- a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx +++ b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx @@ -20,12 +20,15 @@ import { WorkflowScheduleFields } from './WorkflowScheduleFields'; import { WorkflowAlertEditor } from './WorkflowAlertEditor'; import { WorkflowAlertSummary } from './WorkflowAlertSummary'; import { useWorkflowAuthoring } from './useWorkflowAuthoring'; +import { useWorkflowAssist } from './useWorkflowAssist'; +import { WorkflowAskAiTab, WorkflowAskAiToggle, WorkflowAssistLockBanner } from './WorkflowAskAiTab'; import { WorkflowChangedField, WorkflowChangeNotice, WorkflowChangesTab, WorkflowChangesToggle, WorkflowChangeTrackingScope, WorkflowEditorSidePanel, WorkflowReferenceChanges, WorkflowRemovedItemRows, focusWorkflowChangeTarget, workflowSessionSaveNeedsConfirmation, } from './WorkflowChangeTracking'; import { ApiError } from '../../lib/apiClient'; +import { useBootstrapStore } from '../../stores/bootstrapStore'; import { WORKFLOW_TASK_ORDER_KEY, parseWorkflowChangeKey, workflowChangeItemKey, workflowTasksInOrder, type WorkflowChange, } from '../../lib/workflowChangeTracking'; @@ -97,6 +100,9 @@ function changeSection(root: HTMLElement, key: string): HTMLElement | null { return root.querySelector(`section[aria-label="${label}"]`); } +/** Where Jump goes: a change from the Changes tab, or one Ask AI made. */ +type WorkflowJumpChange = Pick; + function workflowRunnerSummary(workflow: WorkflowDefinition, options: WorkflowEditorOptions): string { if (workflow.runner_type === 'agent') { const agent = options.agents.find((item) => workflowAgentKey(item) === workflowAgentKey(workflow.selected_agent)); @@ -118,6 +124,7 @@ export function WorkflowEditorDialog({ interactionDisabled = false, initialDraft = null, onSaveOverride, + onReload, }: { scope: WorkflowScope; workflow: WorkflowDefinition | null; @@ -134,6 +141,11 @@ export function WorkflowEditorDialog({ draft: WorkflowDefinition, original: WorkflowDefinition | null, ) => Promise<{ success?: boolean; workflow?: WorkflowDefinition }>; + /** + * Reopen the saved workflow, discarding the draft. Ask AI offers it when the saved workflow + * changed after the editor opened; without it, the reader closes and reopens the editor. + */ + onReload?: () => Promise; }) { const [original] = useState(() => workflow ? structuredClone(workflow) : null, @@ -151,15 +163,18 @@ export function WorkflowEditorDialog({ const [surface, setSurface] = useState<'list' | 'flow'>('list'); const authoringRef = useRef(null); const [changesOpen, setChangesOpen] = useState(false); + const [sideTab, setSideTab] = useState<'changes' | 'askai'>('changes'); const [confirmingSave, setConfirmingSave] = useState(false); - const [panelFocus, setPanelFocus] = useState<{ target: 'tab' | 'confirm' | 'list'; sequence: number } | null>(null); - const [jump, setJump] = useState<{ change: WorkflowChange; retry: boolean; sequence: number } | null>(null); + const [panelFocus, setPanelFocus] = useState<{ target: 'tab' | 'confirm' | 'list' | 'ask'; sequence: number } | null>(null); + const [jump, setJump] = useState<{ change: WorkflowJumpChange; retry: boolean; sequence: number } | null>(null); const focusSequence = useRef(0); const confirmHeadingRef = useRef(null); const changesHeadingRef = useRef(null); const errorRef = useRef(null); const sidePanelBaseId = useId(); const changesToggleId = `${sidePanelBaseId}-changes-toggle`; + const askAiToggleId = `${sidePanelBaseId}-ask-ai-toggle`; + const askAiInputId = `${sidePanelBaseId}-ask-ai-input`; const sidePanelId = `${sidePanelBaseId}-side-panel`; const scopeKey = workflowScopeKey(scope); const fieldDrafts = useMemo(() => ({ @@ -242,8 +257,10 @@ export function WorkflowEditorDialog({ onRollback: (before, after, targetId) => recoverAuthoring(before, after, targetId, false), }); const panelOpen = changesOpen && !readOnly; - const historyBlockedReason = saving ? 'Workflow history is unavailable while saving.' - : authoring.pending || history.pending ? 'Finish or cancel the current confirmation first.' : ''; + // The bootstrap sends this per user: on only where the setting, personal workflows and the role allow it. + const askAiEnabled = useBootstrapStore((state) => state.data?.features?.enable_workflow_ai_assistant === true); + const askAiAvailable = askAiEnabled && scope.type === 'personal' && !readOnly; + const panelTab = askAiAvailable ? sideTab : 'changes'; const onAccessLost = useCallback((status: number) => { setAccessLost(true); history.session.invalidate(); @@ -270,16 +287,31 @@ export function WorkflowEditorDialog({ }, [surface, authoring.focusRequest, accessLost]); const openChanges = (target: 'tab' | 'confirm') => { + setSideTab('changes'); setChangesOpen(true); setPanelFocus({ target, sequence: ++focusSequence.current }); }; + const openAskAi = () => { + setSideTab('askai'); + setConfirmingSave(false); + setChangesOpen(true); + setPanelFocus({ target: 'ask', sequence: ++focusSequence.current }); + }; + const selectSideTab = (tab: string) => { + // Leaving Changes ends a save review; Save starts it again. + if (tab === 'askai') setConfirmingSave(false); + setSideTab(tab === 'askai' ? 'askai' : 'changes'); + }; const closeChanges = (focusToggle: boolean) => { setChangesOpen(false); setConfirmingSave(false); - if (focusToggle) requestAnimationFrame(() => document.getElementById(changesToggleId)?.focus()); + const toggleId = panelTab === 'askai' ? askAiToggleId : changesToggleId; + if (focusToggle) requestAnimationFrame(() => document.getElementById(toggleId)?.focus()); }; - const jumpToChange = (change: WorkflowChange) => { + const jumpToChange = (change: WorkflowJumpChange) => { if (!sidePanelBesideEditor()) closeChanges(false); + // The jump places focus, so it drops the block focus that the change it follows requested, such as Draft with AI's. + authoring.clearFocusRequest(); if (surface === 'flow' && draft.definition_version === 3) { const info = parseWorkflowChangeKey(change.key); const nodeId = change.target.nodeId ?? (info.scope === 'region' ? info.id : undefined); @@ -290,17 +322,47 @@ export function WorkflowEditorDialog({ } setJump({ change, retry: true, sequence: ++focusSequence.current }); }; + // Reloading the saved workflow locks the draft it is about to discard. Close stays available. + const [reloading, setReloading] = useState(false); + const assist = useWorkflowAssist({ + available: askAiAvailable, + history, + options, + workflowId: original?.id || null, + blocked: saving || Boolean(history.pending) || Boolean(authoring.pending) || interactionDisabled || reloading, + onJump: jumpToChange, + }); + // While Ask AI works, the editor is locked so its answer applies to the draft it was sent with. + const assistPending = Boolean(assist.pending); + const draftLocked = assistPending || reloading; + const historyBlockedReason = saving ? 'Workflow history is unavailable while saving.' + : authoring.pending || history.pending ? 'Finish or cancel the current confirmation first.' + : assistPending ? 'Wait for Ask AI to finish, or cancel it.' + : reloading ? 'Wait for the saved workflow to reload.' : ''; + const reloadSaved = onReload ? async () => { + setReloading(true); + try { + await onReload(); + } finally { + setReloading(false); + } + } : undefined; useEffect(() => { if (!panelFocus) return; const frame = requestAnimationFrame(() => { + const selectedTab = () => document.getElementById(sidePanelId)?.querySelector('[role="tab"][aria-selected="true"]'); + const askInput = () => { + const input = document.getElementById(askAiInputId); + return input instanceof HTMLTextAreaElement && !input.disabled ? input : null; + }; const target = panelFocus.target === 'confirm' ? confirmHeadingRef.current : panelFocus.target === 'list' ? changesHeadingRef.current - : document.getElementById(sidePanelId)?.querySelector('[role="tab"][aria-selected="true"]'); + : panelFocus.target === 'ask' ? askInput() ?? selectedTab() : selectedTab(); target?.focus(); }); return () => cancelAnimationFrame(frame); - }, [panelFocus, sidePanelId]); + }, [panelFocus, sidePanelId, askAiInputId]); useEffect(() => { if (!jump) return; @@ -309,7 +371,11 @@ export function WorkflowEditorDialog({ if (!root) return; const { change } = jump; const itemKey = workflowChangeItemKey(change.key); - const exact = root.querySelector(`[data-workflow-change-key="${CSS.escape(change.target.focusKey)}"]`) + const focusKey = CSS.escape(change.target.focusKey); + // A jump made as a change lands, such as Draft with AI's, can run before the highlight's + // deferred render stamps the change key, so the field's own key comes next. + const exact = root.querySelector(`[data-workflow-change-key="${focusKey}"]`) + ?? root.querySelector(`[data-workflow-field-key="${focusKey}"]`) ?? (itemKey ? root.querySelector(`[data-workflow-change-item="${CSS.escape(itemKey)}"]`) : null); if (!exact && jump.retry && surface === 'flow') { // The Flow surface shows one block's fields at a time; the List shows every field. @@ -334,14 +400,44 @@ export function WorkflowEditorDialog({ const onKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Escape' || event.defaultPrevented) return; const active = document.activeElement; - if (!active || !(document.getElementById(sidePanelId)?.contains(active) || active.id === changesToggleId)) return; + const panel = document.getElementById(sidePanelId); + if (!active || !(panel?.contains(active) || active.id === changesToggleId || active.id === askAiToggleId)) return; + // An open # menu or document picker closes itself on this Escape, and the panel stays open. + const picker = panel?.querySelector('[data-context-picker]'); + if (picker || active.closest('[data-composer-menu-open]')) { + if (picker?.contains(active)) { + // The picker's search box goes with it, so carry on in the Ask AI input. + window.setTimeout(() => { + if (!document.getElementById(sidePanelId)?.contains(document.activeElement)) { + document.getElementById(askAiInputId)?.focus(); + } + }, 0); + } + return; + } event.preventDefault(); event.stopPropagation(); closeChanges(true); }; window.addEventListener('keydown', onKeyDown, true); return () => window.removeEventListener('keydown', onKeyDown, true); - }, [panelOpen, sidePanelId, changesToggleId]); + }, [panelOpen, panelTab, sidePanelId, changesToggleId, askAiToggleId, askAiInputId]); + + // When the lock ends and took focus with it (its Cancel button is gone), carry on in Ask AI. + const wasAssistPending = useRef(false); + useEffect(() => { + const was = wasAssistPending.current; + wasAssistPending.current = assistPending; + if (!was || assistPending) return undefined; + const frame = requestAnimationFrame(() => { + const active = document.activeElement; + if (active && active !== document.body) return; + const input = document.getElementById(askAiInputId); + const target = input instanceof HTMLTextAreaElement && !input.disabled ? input : document.getElementById(askAiToggleId); + target?.focus(); + }); + return () => cancelAnimationFrame(frame); + }, [assistPending, askAiInputId, askAiToggleId]); const shownError = useRef(error); useEffect(() => { @@ -410,7 +506,7 @@ export function WorkflowEditorDialog({ return; } history.session.closeGroup(); - if (saving || history.session.saving || readOnly || authoring.pending || history.session.getSnapshot().pending) { + if (saving || history.session.saving || readOnly || authoring.pending || history.session.getSnapshot().pending || draftLocked) { return; } const savingDraft = history.session.draft; @@ -486,8 +582,14 @@ export function WorkflowEditorDialog({ onChange={(value) => authoring.execute({ type: 'task', taskId: task.id, value, expected: task })} durableExecution={draft.durable_execution === true} onNeedsDurable={() => setWorkflow((current) => ({ ...current, durable_execution: true }))} - structuredNode={node} onStructuredNodeChange={onNodeChange} /> + structuredNode={node} onStructuredNodeChange={onNodeChange} + onAskAi={askAiAvailable ? () => askAiAboutTask(task.id) : undefined} + draftWithAi={askAiAvailable ? assist.draftWithAi(task.id) : undefined} /> ); + const askAiAboutTask = (taskId: string) => { + assist.setFocusTask(taskId); + openAskAi(); + }; return ( @@ -500,21 +602,35 @@ export function WorkflowEditorDialog({ tall bodyClassName="flex min-h-0" footer={ - <> - {!readOnly ? (panelOpen ? closeChanges(false) : openChanges('tab'))} /> : null} - - {readOnly ? 'Close' : 'Cancel'} - - {!readOnly ? ( - void save()}> - {saving ? 'Saving…' : 'Save workflow'} + // A narrow screen can't fit the toggles, Cancel and Save on one line once Ask AI is + // offered, so Cancel and Save move together to a second line instead of wrapping a label. +
+ {askAiAvailable ? (panelOpen && panelTab === 'askai' ? closeChanges(false) : openAskAi())} /> : null} + {!readOnly ? (panelOpen && panelTab === 'changes' ? closeChanges(false) : openChanges('tab'))} /> : null} +
+ + {readOnly ? 'Close' : 'Cancel'} - ) : null} - + {!readOnly ? ( + void save()}> + {saving ? 'Saving…' : 'Save workflow'} + + ) : null} +
+
} > -