Compare commits

...

4 Commits

Author SHA1 Message Date
Charles Yao
c3681a3796 fix(web): refresh draft into canvas before copilot checklist re-check 2026-08-10 03:27:17 -07:00
Charles Yao
84ed12ba3b feat(web): copilot panel — fix pre-publish checklist issues
Extend the Workflow Copilot editor panel to fix pre-publish checklist
errors alongside failed runs.

- Detect blocking checklist items client-side via useChecklist(nodes,
  edges, {flowType}) and offer a "Fix N checklist issues" entry next to
  the failed-run entry, splitting config errors (auto-fixable) from
  unconnected/plugin-missing items (surfaced but not auto-fixed in v1).
- Extend use-copilot-session's create path to POST checklist_errors
  (new startChecklistFix) alongside the existing failed_run_id path
  (startFix unchanged).
- Drive checklist.await_recheck: re-run the checklist against the
  current draft and submit the recheck action with
  passed = (fixable remaining === 0) and the full remaining list.
- Add copilot.checklist* i18n keys to en-US, matching the existing
  copilot.* keys' en-US-only precedent for this feature.
2026-08-10 03:27:16 -07:00
Charles Yao
b9bf6d01e0 feat(web): surface Workflow Copilot inside the workflow editor
Add a copilot toggle button next to Test Run in the workflow header and
a left-mounted panel that runs the copilot Fix flow (start fix, live
SSE progress log, approve_repair/run_verify/provide_testdata/publish/
undo/re_fix actions, and a message box), auto-resolving the app id
(workflow store `appId`), workspace id (`currentWorkspaceAtom`), and
the most recent failed run (`useWorkflowRunHistory`) instead of
requiring manual entry.

Extracts the session/SSE request logic from the standalone
`/workflow-copilot-debug` page into a shared
`app/components/workflow-copilot` module (`use-copilot-session`) so
both the debug page and the new panel drive the same implementation.

Adds a `showWorkflowCopilotPanel` store flag (defaults false) mirroring
the existing `showEnvPanel` pattern, so the feature is fully additive
and does not affect rag-pipeline/snippet editors that reuse the same
header/panel shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 03:27:16 -07:00
Charles Yao
08387f43e3 feat(workflow-copilot): minimal debug page to drive the Fix backend 2026-08-10 03:27:16 -07:00
16 changed files with 1289 additions and 16 deletions

View File

@@ -0,0 +1,188 @@
'use client'
import { useState } from 'react'
import { COPILOT_ACTION_KINDS } from '@/app/components/workflow-copilot/types'
import { useCopilotSession } from '@/app/components/workflow-copilot/use-copilot-session'
import { API_PREFIX } from '@/config'
// Throwaway developer tool for driving the workflow-copilot Fix backend by hand.
// No i18n, no design polish, no reusable UI components on purpose.
// Session/stream request logic lives in `app/components/workflow-copilot/use-copilot-session.ts`,
// shared with the in-editor Workflow Copilot panel (see `app/components/workflow/panel/workflow-copilot-panel`).
export default function WorkflowCopilotDebugPage() {
const [baseUrl, setBaseUrl] = useState(API_PREFIX)
const [appId, setAppId] = useState('')
const [failedRunId, setFailedRunId] = useState('')
const [workspaceId, setWorkspaceId] = useState('')
const [messageText, setMessageText] = useState('')
const { view, lastRaw, lastError, progressLog, startFix, refresh, runAction, sendMessage } =
useCopilotSession({ baseUrl, workspaceId })
const handleStartFix = async () => {
await startFix(appId, failedRunId)
}
const handleGet = async () => {
await refresh()
}
const handleAction = async (kind: (typeof COPILOT_ACTION_KINDS)[number]) => {
await runAction(kind, kind === 'provide_testdata' ? { mode: 'mock' } : {})
}
const handleSendMessage = async () => {
const ok = await sendMessage(messageText)
if (ok) setMessageText('')
}
return (
<div style={{ padding: 24, fontFamily: 'monospace', maxWidth: 960 }}>
<h1>Workflow Copilot Debug</h1>
<p>Throwaway developer tool for driving the copilot Fix backend by hand.</p>
<fieldset style={{ marginBottom: 16 }}>
<legend>Connection</legend>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 480 }}>
<label>
Base URL
<input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
style={{ width: '100%' }}
/>
</label>
<label>
App ID
<input
value={appId}
onChange={(e) => setAppId(e.target.value)}
style={{ width: '100%' }}
/>
</label>
<label>
Failed Run ID
<input
value={failedRunId}
onChange={(e) => setFailedRunId(e.target.value)}
style={{ width: '100%' }}
/>
</label>
<label>
Workspace ID
<input
value={workspaceId}
onChange={(e) => setWorkspaceId(e.target.value)}
style={{ width: '100%' }}
/>
</label>
</div>
<div style={{ marginTop: 8 }}>
<button
type="button"
onClick={() => {
void handleStartFix()
}}
>
Start Fix
</button>
<button
type="button"
onClick={() => {
void handleGet()
}}
style={{ marginLeft: 8 }}
>
Refresh (GET)
</button>
</div>
</fieldset>
<fieldset style={{ marginBottom: 16 }}>
<legend>Actions</legend>
<div>
{COPILOT_ACTION_KINDS.map((kind) => (
<button
key={kind}
type="button"
onClick={() => {
void handleAction(kind)
}}
style={{ marginRight: 8, marginBottom: 8 }}
>
{kind}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8, maxWidth: 480 }}>
<input
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
placeholder="message text"
style={{ flex: 1 }}
/>
<button
type="button"
onClick={() => {
void handleSendMessage()
}}
>
Send Message
</button>
</div>
</fieldset>
{lastError && <div style={{ color: 'red', marginBottom: 16 }}>Error: {lastError}</div>}
<fieldset style={{ marginBottom: 16 }}>
<legend>Status</legend>
{view ? (
<ul>
<li>SessionID: {view.SessionID}</li>
<li>State: {view.State}</li>
<li>Version: {view.Version}</li>
<li>CanvasReadOnly: {String(view.CanvasReadOnly)}</li>
<li>RunStatus: {view.RunStatus}</li>
<li>Interrupted: {String(view.Interrupted)}</li>
</ul>
) : (
<p>No session yet.</p>
)}
</fieldset>
<fieldset style={{ marginBottom: 16 }}>
<legend>Conversation</legend>
<ul>
{(view?.Conversation ?? []).map((entry) => (
<li key={entry.Seq}>
{entry.Seq}
{' · '}
{entry.Kind}
{' · '}
{JSON.stringify(entry.Payload)}
</li>
))}
</ul>
</fieldset>
<fieldset style={{ marginBottom: 16 }}>
<legend>Progress Log (stream)</legend>
<ul>
{progressLog.map((entry) => (
<li key={entry.id}>
{entry.event}
{' · '}
{JSON.stringify(entry.data)}
</li>
))}
</ul>
</fieldset>
<fieldset>
<legend>Last Raw Response</legend>
<pre>{JSON.stringify(lastRaw, null, 2)}</pre>
</fieldset>
</div>
)
}

View File

@@ -40,6 +40,9 @@ const WorkflowHeader = () => {
middle: <FeaturesTrigger />,
chatVariableTrigger: <ChatVariableTrigger />,
},
controls: {
showWorkflowCopilotButton: true,
},
runAndHistoryProps: {
showRunButton: !isChatMode,
showPreviewButton: isChatMode,

View File

@@ -0,0 +1,12 @@
# Workflow Copilot
Shared session/stream logic for the enterprise workflow-copilot Fix API, consumed by
both the standalone debug page and the in-editor Workflow Copilot panel.
## Internal Modules
None.
## External Modules
- `config`: `CSRF_COOKIE_NAME`, `CSRF_HEADER_NAME` for authenticated console requests.

View File

@@ -0,0 +1,86 @@
// Contracts for the enterprise `workflow-copilot` Fix session API. There is no
// generated contract for this endpoint yet (enterprise-only, still under active
// design), so these mirror the backend's JSON responses by hand until one exists.
export type SessionView = {
SessionID: string
AppID: string
Version: number
State: string
CanvasReadOnly: boolean
RunStatus: string
Interrupted: boolean
Conversation: {
Seq: number
Kind: string
Payload: Record<string, unknown>
AtVersion: number
}[]
}
export type ProgressEntry = {
id: number
event: string
data: unknown
}
// Mirrors the backend's `bizcopilot.ChecklistError` JSON shape exactly (see
// `pkg/enterprise/biz/copilot/models.go`). Used both to seed a checklist-fix
// session (`checklist_errors`) and to report the still-failing items on a
// `recheck` action (`remaining`).
export type ChecklistErrorPayload = {
node_id: string
node_type: string
title: string
messages: string[]
unconnected: boolean
plugin_missing: boolean
}
// The state at which the backend has applied a checklist repair to the draft
// and is waiting for the frontend to re-run its client-side checklist and
// report the result via a `recheck` action. Mirrors `bizcopilot.ChecklistAwaitRecheck`.
export const CHECKLIST_AWAIT_RECHECK_STATE = 'checklist.await_recheck'
// Fire-and-forget actions rendered as generic buttons (no computed payload
// beyond the odd `provide_testdata` special-case in the panel).
export const COPILOT_MANUAL_ACTION_KINDS = [
'approve_repair',
'run_verify',
'provide_testdata',
'publish',
'undo',
're_fix',
] as const
// `recheck` carries a computed `{ passed, remaining }` payload (see
// `ChecklistErrorPayload`) built from the current client-side checklist, so
// it is driven by dedicated panel logic rather than a generic button.
export const COPILOT_ACTION_KINDS = [...COPILOT_MANUAL_ACTION_KINDS, 'recheck'] as const
export type CopilotActionKind = (typeof COPILOT_ACTION_KINDS)[number]
export function isSessionView(value: unknown): value is SessionView {
if (typeof value !== 'object' || value === null) return false
return 'SessionID' in value && 'Version' in value
}
// Parses one `event: <kind>\ndata: <json>` SSE frame (already split on the `\n\n` delimiter).
export function parseSSEFrame(frame: string): { event: string; data: unknown } | null {
const lines = frame.split('\n')
let event = 'message'
const dataLines: string[] = []
lines.forEach((line) => {
if (line.startsWith('event:')) event = line.slice(6).trim()
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
})
if (dataLines.length === 0) return null
const raw = dataLines.join('\n')
let data: unknown = raw
try {
data = JSON.parse(raw)
} catch {
// not JSON, keep the raw string
}
return { event, data }
}

View File

@@ -0,0 +1,253 @@
'use client'
import type { ChecklistErrorPayload, CopilotActionKind, ProgressEntry, SessionView } from './types'
import Cookies from 'js-cookie'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from '@/config'
import { isSessionView, parseSSEFrame } from './types'
// What seeds a new Fix session: either a failed workflow run (existing
// run-fix flow) or a set of client-detected pre-publish checklist errors
// (checklist-fix flow). Exactly one of the two is sent to the backend.
type CreateSessionTarget = { failedRunId: string } | { checklistErrors: ChecklistErrorPayload[] }
export type UseCopilotSessionParams = {
baseUrl: string
workspaceId: string
}
export type UseCopilotSessionResult = {
view: SessionView | null
lastRaw: unknown
lastError: string
progressLog: ProgressEntry[]
/** Creates a new Fix session for `failedRunId` and starts streaming its progress. */
startFix: (appId: string, failedRunId: string) => Promise<boolean>
/** Creates a new checklist-fix session for `checklistErrors` and starts streaming its progress. */
startChecklistFix: (appId: string, checklistErrors: ChecklistErrorPayload[]) => Promise<boolean>
/** Re-fetches the current session (GET). No-op if no session has been created yet. */
refresh: () => Promise<boolean>
/** Posts an action (`approve_repair`, `run_verify`, ...) against the current session. */
runAction: (kind: CopilotActionKind, payload?: Record<string, unknown>) => Promise<boolean>
/** Posts a free-text message against the current session. */
sendMessage: (text: string) => Promise<boolean>
}
function useCopilotApi(baseUrl: string, workspaceId: string) {
// Memoized so the returned methods stay referentially stable across renders
// unless the connection inputs actually change (also what earns the `use` prefix).
return useMemo(() => {
const root = `${baseUrl.replace(/\/$/, '')}/enterprise/workflow-copilot`
const headers = (json: boolean, csrf: boolean): Record<string, string> => {
const h: Record<string, string> = {}
if (json) h['Content-Type'] = 'application/json'
if (csrf) h[CSRF_HEADER_NAME] = Cookies.get(CSRF_COOKIE_NAME()) || ''
if (workspaceId) h['X-Workspace-Id'] = workspaceId
return h
}
const create = (appId: string, target: CreateSessionTarget) =>
fetch(`${root}/sessions`, {
method: 'POST',
credentials: 'include',
headers: headers(true, true),
body: JSON.stringify({
app_id: appId,
...('failedRunId' in target
? { failed_run_id: target.failedRunId }
: { checklist_errors: target.checklistErrors }),
}),
})
const get = (id: string) =>
fetch(`${root}/sessions/${id}`, {
credentials: 'include',
headers: headers(false, false),
})
const action = (
id: string,
kind: string,
baseVersion: number,
payload: Record<string, unknown> = {},
) =>
fetch(`${root}/sessions/${id}/actions`, {
method: 'POST',
credentials: 'include',
headers: headers(true, true),
body: JSON.stringify({ kind, payload, base_version: baseVersion }),
})
const message = (id: string, text: string, baseVersion: number) =>
fetch(`${root}/sessions/${id}/messages`, {
method: 'POST',
credentials: 'include',
headers: headers(true, true),
body: JSON.stringify({ text, base_version: baseVersion }),
})
const streamURL = (id: string) => `${root}/sessions/${id}/stream`
return { create, get, action, message, streamURL }
}, [baseUrl, workspaceId])
}
/**
* Owns the workflow-copilot Fix session lifecycle: creating a session, streaming
* its SSE progress log, posting actions/messages, and refreshing session state.
* Shared by the standalone `/workflow-copilot-debug` page and the in-editor
* Workflow Copilot panel so both drive the exact same request/stream logic.
*/
export function useCopilotSession({
baseUrl,
workspaceId,
}: UseCopilotSessionParams): UseCopilotSessionResult {
const [view, setView] = useState<SessionView | null>(null)
const [lastRaw, setLastRaw] = useState<unknown>(null)
const [lastError, setLastError] = useState('')
const [progressLog, setProgressLog] = useState<ProgressEntry[]>([])
const abortRef = useRef<AbortController | null>(null)
const progressIdRef = useRef(0)
const api = useCopilotApi(baseUrl, workspaceId)
// Cancel the in-flight stream when the owning component unmounts.
useEffect(() => {
return () => {
abortRef.current?.abort()
}
}, [])
const pushProgress = useCallback((event: string, data: unknown) => {
progressIdRef.current += 1
setProgressLog((prev) => [...prev, { id: progressIdRef.current, event, data }])
}, [])
const applyResponse = useCallback(async (res: Response): Promise<unknown> => {
const text = await res.text()
let parsed: unknown = text
try {
parsed = JSON.parse(text)
} catch {
// leave as raw text; still shown in the "Last Raw Response" panel
}
setLastRaw(parsed)
setLastError(res.ok ? '' : `HTTP ${res.status}`)
if (isSessionView(parsed)) setView(parsed)
return parsed
}, [])
const startStream = useCallback(
(sessionId: string) => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
fetch(api.streamURL(sessionId), { credentials: 'include', signal: controller.signal })
.then(async (res) => {
const reader = res.body?.getReader()
if (!reader) return
const decoder = new TextDecoder()
let buffer = ''
let done = false
while (!done) {
const result = await reader.read()
done = result.done
if (result.value) buffer += decoder.decode(result.value, { stream: true })
const frames = buffer.split('\n\n')
buffer = frames.pop() ?? ''
frames.forEach((frame) => {
const parsed = parseSSEFrame(frame)
if (parsed) pushProgress(parsed.event, parsed.data)
})
}
})
.catch((err: unknown) => {
if (controller.signal.aborted) return
pushProgress('error', String(err))
})
},
[api, pushProgress],
)
const beginSession = useCallback(
async (appId: string, target: CreateSessionTarget) => {
try {
const res = await api.create(appId, target)
const parsed = await applyResponse(res)
if (isSessionView(parsed)) startStream(parsed.SessionID)
return true
} catch (err) {
setLastError(String(err))
return false
}
},
[api, applyResponse, startStream],
)
const startFix = useCallback(
(appId: string, failedRunId: string) => beginSession(appId, { failedRunId }),
[beginSession],
)
const startChecklistFix = useCallback(
(appId: string, checklistErrors: ChecklistErrorPayload[]) =>
beginSession(appId, { checklistErrors }),
[beginSession],
)
const refresh = useCallback(async () => {
if (!view) return false
try {
const res = await api.get(view.SessionID)
await applyResponse(res)
return true
} catch (err) {
setLastError(String(err))
return false
}
}, [api, applyResponse, view])
const runAction = useCallback(
async (kind: CopilotActionKind, payload: Record<string, unknown> = {}) => {
if (!view) return false
try {
const res = await api.action(view.SessionID, kind, view.Version, payload)
await applyResponse(res)
return true
} catch (err) {
setLastError(String(err))
return false
}
},
[api, applyResponse, view],
)
const sendMessage = useCallback(
async (text: string) => {
if (!view) return false
try {
const res = await api.message(view.SessionID, text, view.Version)
await applyResponse(res)
return true
} catch (err) {
setLastError(String(err))
return false
}
},
[api, applyResponse, view],
)
return {
view,
lastRaw,
lastError,
progressLog,
startFix,
startChecklistFix,
refresh,
runAction,
sendMessage,
}
}

View File

@@ -16,6 +16,7 @@ import OnlineUsers from './online-users'
import RunAndHistory from './run-and-history'
import ScrollToSelectedNodeButton from './scroll-to-selected-node-button'
import { VersionHistoryButton } from './version-history-button'
import WorkflowCopilotButton from './workflow-copilot-button'
export type HeaderInNormalProps = {
components?: {
@@ -27,6 +28,7 @@ export type HeaderInNormalProps = {
controls?: {
showEnvButton?: boolean
showGlobalVariableButton?: boolean
showWorkflowCopilotButton?: boolean
}
runAndHistoryProps?: RunAndHistoryProps
}
@@ -41,12 +43,14 @@ const HeaderInNormal = ({ components, controls, runAndHistoryProps }: HeaderInNo
const setShowVariableInspectPanel = useStore((s) => s.setShowVariableInspectPanel)
const setShowChatVariablePanel = useStore((s) => s.setShowChatVariablePanel)
const setShowGlobalVariablePanel = useStore((s) => s.setShowGlobalVariablePanel)
const setShowWorkflowCopilotPanel = useStore((s) => s.setShowWorkflowCopilotPanel)
const nodes = useNodes<StartNodeType>()
const selectedNode = nodes.find((node) => node.data.selected)
const { handleBackupDraft } = useWorkflowRun()
const { closeAllInputFieldPanels } = useInputFieldPanel()
const showEnvButton = controls?.showEnvButton !== false
const showGlobalVariableButton = controls?.showGlobalVariableButton !== false
const showWorkflowCopilotButton = controls?.showWorkflowCopilotButton === true
const showContextButtons =
!!components?.chatVariableTrigger || showEnvButton || showGlobalVariableButton
@@ -61,6 +65,7 @@ const HeaderInNormal = ({ components, controls, runAndHistoryProps }: HeaderInNo
setShowVariableInspectPanel(false)
setShowChatVariablePanel(false)
setShowGlobalVariablePanel(false)
setShowWorkflowCopilotPanel(false)
closeAllInputFieldPanels()
}, [
workflowStore,
@@ -73,6 +78,7 @@ const HeaderInNormal = ({ components, controls, runAndHistoryProps }: HeaderInNo
setShowVariableInspectPanel,
setShowChatVariablePanel,
setShowGlobalVariablePanel,
setShowWorkflowCopilotPanel,
closeAllInputFieldPanels,
])
@@ -87,6 +93,7 @@ const HeaderInNormal = ({ components, controls, runAndHistoryProps }: HeaderInNo
{components?.left}
<Divider type="vertical" className="mx-auto h-3.5" />
<RunAndHistory {...runAndHistoryProps} />
{showWorkflowCopilotButton && <WorkflowCopilotButton disabled={nodesReadOnly} />}
{showContextButtons && (
<div className="shrink-0 cursor-pointer rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs backdrop-blur-[10px]">
{components?.chatVariableTrigger}

View File

@@ -132,6 +132,7 @@ const ViewHistory = ({ withText, onClearLogAndMessageModal, historyUrl }: ViewHi
historyWorkflowData: item,
showInputsPanel: false,
showEnvPanel: false,
showWorkflowCopilotPanel: false,
})
closeAllInputFieldPanels()
handleBackupDraft()

View File

@@ -0,0 +1,49 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/workflow/store'
import useTheme from '@/hooks/use-theme'
const WorkflowCopilotButton = ({ disabled }: { disabled: boolean }) => {
const { t } = useTranslation()
const { theme } = useTheme()
const showWorkflowCopilotPanel = useStore((s) => s.showWorkflowCopilotPanel)
const setShowWorkflowCopilotPanel = useStore((s) => s.setShowWorkflowCopilotPanel)
const label = t(($) => $['copilot.buttonTooltip'], { ns: 'workflow' })
const handleClick = () => {
setShowWorkflowCopilotPanel(!showWorkflowCopilotPanel)
}
return (
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={label}
aria-pressed={showWorkflowCopilotPanel}
className={cn(
'rounded-lg border border-transparent p-2',
theme === 'dark' &&
showWorkflowCopilotPanel &&
'border-black/5 bg-white/10 backdrop-blur-xs',
)}
variant="ghost"
disabled={disabled}
onClick={handleClick}
>
<span
aria-hidden
className="i-ri-magic-line size-4 text-components-button-secondary-text"
/>
</Button>
}
/>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}
export default memo(WorkflowCopilotButton)

View File

@@ -16,6 +16,13 @@ const VersionHistoryPanel = dynamic(
},
)
const WorkflowCopilotPanel = dynamic(
() => import('@/app/components/workflow/panel/workflow-copilot-panel'),
{
ssr: false,
},
)
export type PanelProps = {
components?: {
left?: React.ReactNode
@@ -96,6 +103,7 @@ const Panel: FC<PanelProps> = ({ components, versionHistoryPanelProps }) => {
const showEnvPanel = useStore((s) => s.showEnvPanel)
const isRestoring = useStore((s) => s.isRestoring)
const showWorkflowVersionHistoryPanel = useStore((s) => s.showWorkflowVersionHistoryPanel)
const showWorkflowCopilotPanel = useStore((s) => s.showWorkflowCopilotPanel)
// widths used for adaptive layout
const workflowCanvasWidth = useStore((s) => s.workflowCanvasWidth)
@@ -127,23 +135,34 @@ const Panel: FC<PanelProps> = ({ components, versionHistoryPanelProps }) => {
const otherPanelRef = useResizeObserver(setOtherPanelWidth)
return (
<div
ref={rightPanelRef}
data-workflow-right-panel
tabIndex={-1}
className={cn('absolute top-14 right-0 bottom-1 z-10 flex outline-hidden')}
key={`${isRestoring}`}
>
{components?.left}
{!!selectedNode && <NodePanel {...selectedNode} />}
<div className="relative" ref={otherPanelRef}>
{components?.right}
{showWorkflowVersionHistoryPanel && versionHistoryPanelProps && (
<VersionHistoryPanel {...versionHistoryPanelProps} />
)}
{showEnvPanel && <EnvPanel />}
<>
{showWorkflowCopilotPanel && (
<div
data-workflow-left-panel
tabIndex={-1}
className={cn('absolute top-14 left-0 bottom-1 z-10 flex outline-hidden')}
>
<WorkflowCopilotPanel />
</div>
)}
<div
ref={rightPanelRef}
data-workflow-right-panel
tabIndex={-1}
className={cn('absolute top-14 right-0 bottom-1 z-10 flex outline-hidden')}
key={`${isRestoring}`}
>
{components?.left}
{!!selectedNode && <NodePanel {...selectedNode} />}
<div className="relative" ref={otherPanelRef}>
{components?.right}
{showWorkflowVersionHistoryPanel && versionHistoryPanelProps && (
<VersionHistoryPanel {...versionHistoryPanelProps} />
)}
{showEnvPanel && <EnvPanel />}
</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,65 @@
import type { ChecklistItem } from '@/app/components/workflow/hooks/use-checklist'
import { Button } from '@langgenius/dify-ui/button'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
export type ChecklistToFixProps = {
fixableItems: ChecklistItem[]
unfixableItems: ChecklistItem[]
canStartChecklistFix: boolean
isStarting: boolean
onStartChecklistFix: () => void
}
// Sibling entry point to `RunToFix`: offers to start a checklist-fix session
// for the pre-publish checklist issues `useChecklist` currently reports on
// this app's draft, alongside the existing failed-run entry.
function ChecklistToFix({
fixableItems,
unfixableItems,
canStartChecklistFix,
isStarting,
onStartChecklistFix,
}: ChecklistToFixProps) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-y-3 px-4 py-3">
<div className="system-sm-semibold-uppercase text-text-secondary">
{t(($) => $['copilot.checklistFixTitle'], { ns: 'workflow' })}
</div>
<div className="rounded-lg border border-components-panel-border bg-components-panel-bg px-3 py-2">
<div className="system-sm-regular text-text-secondary">
{t(($) => $['copilot.checklistFixSummary'], {
ns: 'workflow',
count: fixableItems.length,
})}
</div>
{unfixableItems.length > 0 && (
<div className="mt-1 system-xs-regular text-text-quaternary">
{t(($) => $['copilot.checklistUnfixableNote'], {
ns: 'workflow',
count: unfixableItems.length,
})}
</div>
)}
</div>
<Button
variant="secondary"
size="small"
disabled={!canStartChecklistFix || isStarting}
loading={isStarting}
onClick={onStartChecklistFix}
>
{isStarting
? t(($) => $['copilot.starting'], { ns: 'workflow' })
: t(($) => $['copilot.checklistFixAction'], {
ns: 'workflow',
count: fixableItems.length,
})}
</Button>
</div>
)
}
export default memo(ChecklistToFix)

View File

@@ -0,0 +1,209 @@
import type {
CopilotActionKind,
ProgressEntry,
SessionView,
} from '@/app/components/workflow-copilot/types'
import type { ChecklistItem } from '@/app/components/workflow/hooks/use-checklist'
import { Button } from '@langgenius/dify-ui/button'
import { Input } from '@langgenius/dify-ui/input'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import {
CHECKLIST_AWAIT_RECHECK_STATE,
COPILOT_MANUAL_ACTION_KINDS,
} from '@/app/components/workflow-copilot/types'
function useActionLabels(): Record<CopilotActionKind, string> {
const { t } = useTranslation()
return {
approve_repair: t(($) => $['copilot.actionApproveRepair'], { ns: 'workflow' }),
run_verify: t(($) => $['copilot.actionRunVerify'], { ns: 'workflow' }),
provide_testdata: t(($) => $['copilot.actionProvideTestdata'], { ns: 'workflow' }),
publish: t(($) => $['copilot.actionPublish'], { ns: 'workflow' }),
undo: t(($) => $['copilot.actionUndo'], { ns: 'workflow' }),
re_fix: t(($) => $['copilot.actionReFix'], { ns: 'workflow' }),
recheck: t(($) => $['copilot.checklistRecheckAction'], { ns: 'workflow' }),
}
}
function SessionStatus({ view }: { view: SessionView }) {
const { t } = useTranslation()
return (
<div className="grid grid-cols-2 gap-x-2 gap-y-1 rounded-lg border border-components-panel-border bg-components-panel-bg px-3 py-2 system-xs-regular text-text-secondary">
<div className="text-text-tertiary">
{t(($) => $['copilot.sessionIdLabel'], { ns: 'workflow' })}
</div>
<div className="truncate text-right">{view.SessionID}</div>
<div className="text-text-tertiary">
{t(($) => $['copilot.sessionStateLabel'], { ns: 'workflow' })}
</div>
<div className="truncate text-right">{view.State}</div>
<div className="text-text-tertiary">
{t(($) => $['copilot.sessionVersionLabel'], { ns: 'workflow' })}
</div>
<div className="truncate text-right">{view.Version}</div>
<div className="text-text-tertiary">
{t(($) => $['copilot.sessionRunStatusLabel'], { ns: 'workflow' })}
</div>
<div className="truncate text-right">{view.RunStatus}</div>
<div className="text-text-tertiary">
{t(($) => $['copilot.sessionCanvasReadOnlyLabel'], { ns: 'workflow' })}
</div>
<div className="truncate text-right">{String(view.CanvasReadOnly)}</div>
<div className="text-text-tertiary">
{t(($) => $['copilot.sessionInterruptedLabel'], { ns: 'workflow' })}
</div>
<div className="truncate text-right">{String(view.Interrupted)}</div>
</div>
)
}
function ProgressLog({ entries }: { entries: ProgressEntry[] }) {
const { t } = useTranslation()
if (!entries.length) {
return (
<div className="system-xs-regular text-text-quaternary">
{t(($) => $['copilot.progressEmpty'], { ns: 'workflow' })}
</div>
)
}
return (
<div className="flex max-h-60 flex-col gap-y-1 overflow-y-auto rounded-lg border border-components-panel-border bg-components-panel-bg px-3 py-2">
{entries.map((entry) => (
<div key={entry.id} className="system-xs-regular text-text-secondary">
<span className="text-text-accent">{entry.event}</span>
{' · '}
<span className="wrap-break-word">{JSON.stringify(entry.data)}</span>
</div>
))}
</div>
)
}
function ChecklistRecheck({
fixableItems,
unfixableItems,
onRecheck,
isRefreshingDraft,
}: {
fixableItems: ChecklistItem[]
unfixableItems: ChecklistItem[]
onRecheck: () => void
isRefreshingDraft: boolean
}) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-y-2 rounded-lg border border-components-panel-border bg-components-panel-bg px-3 py-2">
<div className="system-sm-semibold-uppercase text-text-secondary">
{t(($) => $['copilot.checklistRecheckTitle'], { ns: 'workflow' })}
</div>
<div className="system-xs-regular text-text-tertiary">
{t(($) => $['copilot.checklistRecheckDescription'], { ns: 'workflow' })}
</div>
{unfixableItems.length > 0 && (
<div className="system-xs-regular text-text-quaternary">
{t(($) => $['copilot.checklistUnfixableNote'], {
ns: 'workflow',
count: unfixableItems.length,
})}
</div>
)}
<Button
variant="secondary"
size="small"
disabled={isRefreshingDraft}
loading={isRefreshingDraft}
onClick={onRecheck}
>
{t(($) => $['copilot.checklistRecheckAction'], { ns: 'workflow' })}
{fixableItems.length > 0 ? ` (${fixableItems.length})` : ''}
</Button>
</div>
)
}
export type CopilotSessionViewProps = {
view: SessionView
progressLog: ProgressEntry[]
onRunAction: (kind: CopilotActionKind) => void
messageText: string
onMessageTextChange: (value: string) => void
onSendMessage: () => void
fixableChecklistItems: ChecklistItem[]
unfixableChecklistItems: ChecklistItem[]
onRecheck: () => void
isRefreshingDraftForRecheck: boolean
}
function CopilotSessionView({
view,
progressLog,
onRunAction,
messageText,
onMessageTextChange,
onSendMessage,
fixableChecklistItems,
unfixableChecklistItems,
onRecheck,
isRefreshingDraftForRecheck,
}: CopilotSessionViewProps) {
const { t } = useTranslation()
const actionLabels = useActionLabels()
return (
<div className="flex flex-col gap-y-3 px-4 py-3">
<SessionStatus view={view} />
{view.State === CHECKLIST_AWAIT_RECHECK_STATE && (
<ChecklistRecheck
fixableItems={fixableChecklistItems}
unfixableItems={unfixableChecklistItems}
onRecheck={onRecheck}
isRefreshingDraft={isRefreshingDraftForRecheck}
/>
)}
<div className="flex flex-col gap-y-2">
<div className="system-sm-semibold-uppercase text-text-secondary">
{t(($) => $['copilot.progressTitle'], { ns: 'workflow' })}
</div>
<ProgressLog entries={progressLog} />
</div>
<div className="flex flex-col gap-y-2">
<div className="system-sm-semibold-uppercase text-text-secondary">
{t(($) => $['copilot.actionsTitle'], { ns: 'workflow' })}
</div>
<div className="flex flex-wrap gap-2">
{COPILOT_MANUAL_ACTION_KINDS.map((kind) => (
<Button key={kind} size="small" onClick={() => onRunAction(kind)}>
{actionLabels[kind]}
</Button>
))}
</div>
</div>
<div className="flex items-center gap-x-2">
<Input
size="small"
value={messageText}
onChange={(e) => onMessageTextChange(e.target.value)}
placeholder={t(($) => $['copilot.messagePlaceholder'], { ns: 'workflow' })}
onKeyDown={(e) => {
if (e.key === 'Enter' && messageText.trim()) onSendMessage()
}}
/>
<Button size="small" disabled={!messageText.trim()} onClick={onSendMessage}>
{t(($) => $['copilot.messageSend'], { ns: 'workflow' })}
</Button>
</div>
</div>
)
}
export default memo(CopilotSessionView)

View File

@@ -0,0 +1,238 @@
import type { ChecklistErrorPayload } from '@/app/components/workflow-copilot/types'
import type { ChecklistItem } from '@/app/components/workflow/hooks/use-checklist'
import type { CommonEdgeType } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { useAtomValue } from 'jotai'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useEdges } from 'reactflow'
import { CHECKLIST_AWAIT_RECHECK_STATE } from '@/app/components/workflow-copilot/types'
import { useCopilotSession } from '@/app/components/workflow-copilot/use-copilot-session'
import { useHooksStore } from '@/app/components/workflow/hooks-store/store'
import { useChecklist } from '@/app/components/workflow/hooks/use-checklist'
import { useIsChatMode } from '@/app/components/workflow/hooks/use-workflow'
import { useWorkflowRefreshDraft } from '@/app/components/workflow/hooks/use-workflow-refresh-draft'
import { useStore } from '@/app/components/workflow/store'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { API_PREFIX } from '@/config'
import { currentWorkspaceAtom } from '@/context/workspace-state'
import { useWorkflowRunHistory } from '@/service/use-workflow'
import ChecklistToFix from './checklist-to-fix'
import CopilotSessionView from './copilot-session-view'
import RunToFix from './run-to-fix'
// Maps a client-side `ChecklistItem` onto the backend's `ChecklistError` JSON
// shape, used both to seed a checklist-fix session and to report the
// still-failing items on a `recheck` action.
const toChecklistErrorPayload = (item: ChecklistItem): ChecklistErrorPayload => ({
node_id: item.id,
node_type: String(item.type),
title: item.title,
messages: item.errorMessages,
unconnected: !!item.unConnected,
plugin_missing: !!item.isPluginMissing,
})
function WorkflowCopilotPanel() {
const { t } = useTranslation()
const appId = useStore((s) => s.appId)
const setShowWorkflowCopilotPanel = useStore((s) => s.setShowWorkflowCopilotPanel)
const isChatMode = useIsChatMode()
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const historyUrl = appId
? isChatMode
? `/apps/${appId}/advanced-chat/workflow-runs`
: `/apps/${appId}/workflow-runs`
: undefined
const { data: runHistoryData, isLoading: isRunHistoryLoading } = useWorkflowRunHistory(
historyUrl,
!!historyUrl,
)
// TODO: the run history endpoint does not support server-side status filtering yet,
// so we scan the most recent page for the latest failed run client-side.
const latestFailedRun = useMemo(
() => runHistoryData?.data.find((run) => run.status === WorkflowRunningStatus.Failed),
[runHistoryData],
)
const [manualRunId, setManualRunId] = useState('')
const [isStarting, setIsStarting] = useState(false)
const [isStartingChecklistFix, setIsStartingChecklistFix] = useState(false)
const [messageText, setMessageText] = useState('')
// The pre-publish checklist is computed client-side from the current draft
// canvas, exactly like the header checklist popover and the pre-run
// validation gate — it stays reactive to canvas edits made both by the
// user and (once landed) by the copilot's own applied repairs.
const nodes = useNodes()
const edges = useEdges<CommonEdgeType>()
const flowType = useHooksStore((s) => s.configsMap?.flowType)
const checklistItems = useChecklist(nodes, edges, { flowType })
// Config errors (`errorMessages`) are addressable via the backend's
// `set_node_config` repair; unconnected nodes and missing plugins are
// structural and not auto-fixable in v1 (see `bizcopilot.ChecklistError`
// doc comment), so they're surfaced but excluded from the fixable count
// even when they also carry a message (e.g. "plugin not installed").
const { fixableChecklistItems, unfixableChecklistItems } = useMemo(() => {
const fixable: ChecklistItem[] = []
const unfixable: ChecklistItem[] = []
checklistItems.forEach((item) => {
if (item.unConnected || item.isPluginMissing) unfixable.push(item)
else if (item.errorMessages.length > 0) fixable.push(item)
})
return { fixableChecklistItems: fixable, unfixableChecklistItems: unfixable }
}, [checklistItems])
const { view, lastError, progressLog, startFix, startChecklistFix, runAction, sendMessage } =
useCopilotSession({
baseUrl: API_PREFIX,
workspaceId: currentWorkspace.id,
})
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const isSyncingWorkflowDraft = useStore((s) => s.isSyncingWorkflowDraft)
// The copilot applies checklist repairs to the *server* draft (via the
// backend adapter), not the local canvas, so `nodes`/`useChecklist` above
// stay on the stale pre-fix graph until something pulls the draft back in.
// When the session hands control back for a re-check
// (`checklist.await_recheck`), refresh the draft into the canvas once per
// entry into that state, so the re-check reflects the copilot's applied
// fix. This can't clobber unsaved user edits: the canvas is read-only for
// the duration of the session, and the editor's own autosave only pushes
// local changes to the draft, never pulls.
useEffect(() => {
if (view?.State === CHECKLIST_AWAIT_RECHECK_STATE) handleRefreshWorkflowDraft()
}, [view?.State, handleRefreshWorkflowDraft])
const resolvedRunId = manualRunId.trim() || latestFailedRun?.id || ''
const canStartFix = !!appId && !!currentWorkspace.id && !!resolvedRunId
const canStartChecklistFix = !!appId && !!currentWorkspace.id && fixableChecklistItems.length > 0
const handleStartFix = useCallback(() => {
if (!canStartFix) return
setIsStarting(true)
void startFix(appId, resolvedRunId).finally(() => setIsStarting(false))
}, [appId, canStartFix, resolvedRunId, startFix])
const handleStartChecklistFix = useCallback(() => {
if (!canStartChecklistFix || !appId) return
setIsStartingChecklistFix(true)
void startChecklistFix(appId, checklistItems.map(toChecklistErrorPayload)).finally(() =>
setIsStartingChecklistFix(false),
)
}, [appId, canStartChecklistFix, checklistItems, startChecklistFix])
const handleRunAction = useCallback(
(kind: Parameters<typeof runAction>[0]) => {
void runAction(kind, kind === 'provide_testdata' ? { mode: 'mock' } : {})
},
[runAction],
)
// Drives `checklist.await_recheck`: report the *current* client-side
// checklist back to the session. `passed` only reflects fixable (config)
// items — unfixable structural ones can't be resolved by this loop, so
// they mustn't block it from handing off to the publish/keep/re-fix
// decision gate. All still-outstanding items (fixable and unfixable) are
// still sent in `remaining` so the session's record stays accurate.
const handleRecheck = useCallback(() => {
void runAction('recheck', {
passed: fixableChecklistItems.length === 0,
remaining: checklistItems.map(toChecklistErrorPayload),
})
}, [checklistItems, fixableChecklistItems, runAction])
const handleSendMessage = useCallback(() => {
if (!messageText.trim()) return
void sendMessage(messageText).then((ok) => {
if (ok) setMessageText('')
})
}, [messageText, sendMessage])
return (
<div
className={cn(
'relative flex h-full w-[420px] flex-col rounded-r-2xl border border-components-panel-border bg-components-panel-bg-alt',
)}
>
<div className="flex shrink-0 items-center justify-between p-4 pb-0 system-xl-semibold text-text-primary">
{t(($) => $['copilot.panelTitle'], { ns: 'workflow' })}
<div className="flex items-center">
<button
type="button"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
className="flex size-6 cursor-pointer items-center justify-center"
onClick={() => setShowWorkflowCopilotPanel(false)}
>
<span aria-hidden className="i-ri-close-line size-4 text-text-tertiary" />
</button>
</div>
</div>
<div className="shrink-0 px-4 py-1 system-sm-regular text-text-tertiary">
{t(($) => $['copilot.panelDescription'], { ns: 'workflow' })}
</div>
<div className="shrink-0 px-4 py-2 system-xs-regular text-text-tertiary">
<div>
{t(($) => $['copilot.appLabel'], { ns: 'workflow' })}: {appId || '—'}
</div>
<div>
{t(($) => $['copilot.workspaceLabel'], { ns: 'workflow' })}:{' '}
{currentWorkspace.name || currentWorkspace.id || '—'}
</div>
</div>
<div className="grow overflow-y-auto rounded-b-2xl">
{!view && (
<>
<RunToFix
isRunHistoryLoading={isRunHistoryLoading}
latestFailedRun={latestFailedRun}
manualRunId={manualRunId}
onManualRunIdChange={setManualRunId}
canStartFix={canStartFix}
isStarting={isStarting}
onStartFix={handleStartFix}
/>
{checklistItems.length > 0 && (
<>
<div className="mx-4 border-t border-components-panel-border" />
<ChecklistToFix
fixableItems={fixableChecklistItems}
unfixableItems={unfixableChecklistItems}
canStartChecklistFix={canStartChecklistFix}
isStarting={isStartingChecklistFix}
onStartChecklistFix={handleStartChecklistFix}
/>
</>
)}
</>
)}
{view && (
<CopilotSessionView
view={view}
progressLog={progressLog}
onRunAction={handleRunAction}
messageText={messageText}
onMessageTextChange={setMessageText}
onSendMessage={handleSendMessage}
fixableChecklistItems={fixableChecklistItems}
unfixableChecklistItems={unfixableChecklistItems}
onRecheck={handleRecheck}
isRefreshingDraftForRecheck={isSyncingWorkflowDraft}
/>
)}
{lastError && (
<div className="px-4 pb-3 system-xs-regular text-text-destructive">
{t(($) => $['copilot.errorPrefix'], { ns: 'workflow' })}: {lastError}
</div>
)}
</div>
</div>
)
}
export default memo(WorkflowCopilotPanel)

View File

@@ -0,0 +1,91 @@
import type { WorkflowRunHistory } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import { Input } from '@langgenius/dify-ui/input'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
export type RunToFixProps = {
isRunHistoryLoading: boolean
latestFailedRun?: WorkflowRunHistory
manualRunId: string
onManualRunIdChange: (value: string) => void
canStartFix: boolean
isStarting: boolean
onStartFix: () => void
}
function RunToFix({
isRunHistoryLoading,
latestFailedRun,
manualRunId,
onManualRunIdChange,
canStartFix,
isStarting,
onStartFix,
}: RunToFixProps) {
const { t } = useTranslation()
const { formatTimeFromNow } = useFormatTimeFromNow()
const usesAutoDetectedRun = !manualRunId.trim() && !!latestFailedRun
return (
<div className="flex flex-col gap-y-3 px-4 py-3">
<div className="system-sm-semibold-uppercase text-text-secondary">
{t(($) => $['copilot.runToFixTitle'], { ns: 'workflow' })}
</div>
<div className="rounded-lg border border-components-panel-border bg-components-panel-bg px-3 py-2">
<div className="system-xs-medium text-text-tertiary">
{t(($) => $['copilot.runToFixAutoLabel'], { ns: 'workflow' })}
</div>
{isRunHistoryLoading && (
<div className="system-sm-regular mt-1 text-text-quaternary">
{t(($) => $['copilot.runToFixAutoLoading'], { ns: 'workflow' })}
</div>
)}
{!isRunHistoryLoading && latestFailedRun && (
<div className="mt-1 flex items-center justify-between gap-x-2">
<div className="min-w-0">
<div className="system-sm-medium truncate text-text-primary">
{latestFailedRun.id}
</div>
<div className="system-xs-regular text-text-tertiary">
{formatTimeFromNow((latestFailedRun.finished_at || latestFailedRun.created_at) * 1000)}
</div>
</div>
</div>
)}
{!isRunHistoryLoading && !latestFailedRun && (
<div className="system-sm-regular mt-1 text-text-quaternary">
{t(($) => $['copilot.runToFixAutoEmpty'], { ns: 'workflow' })}
</div>
)}
</div>
<div>
<div className="mb-1 system-xs-medium text-text-tertiary">
{t(($) => $['copilot.runToFixManualLabel'], { ns: 'workflow' })}
</div>
<Input
size="small"
value={manualRunId}
onChange={(e) => onManualRunIdChange(e.target.value)}
placeholder={t(($) => $['copilot.runToFixManualPlaceholder'], { ns: 'workflow' })}
/>
</div>
<Button
variant="primary"
size="small"
disabled={!canStartFix || isStarting}
loading={isStarting}
onClick={onStartFix}
>
{isStarting
? t(($) => $['copilot.starting'], { ns: 'workflow' })
: usesAutoDetectedRun
? t(($) => $['copilot.useThisRun'], { ns: 'workflow' })
: t(($) => $['copilot.startFix'], { ns: 'workflow' })}
</Button>
</div>
)
}
export default memo(RunToFix)

View File

@@ -31,6 +31,8 @@ export type PanelSliceShape = {
setInitShowLastRunTab: (initShowLastRunTab: boolean) => void
activeCommentId?: string | null
setActiveCommentId: (commentId: string | null) => void
showWorkflowCopilotPanel: boolean
setShowWorkflowCopilotPanel: (showWorkflowCopilotPanel: boolean) => void
}
export const createPanelSlice: StateCreator<PanelSliceShape> = (set) => ({
@@ -62,4 +64,7 @@ export const createPanelSlice: StateCreator<PanelSliceShape> = (set) => ({
setInitShowLastRunTab: (initShowLastRunTab) => set(() => ({ initShowLastRunTab })),
activeCommentId: null,
setActiveCommentId: (commentId: string | null) => set(() => ({ activeCommentId: commentId })),
showWorkflowCopilotPanel: false,
setShowWorkflowCopilotPanel: (showWorkflowCopilotPanel) =>
set(() => ({ showWorkflowCopilotPanel })),
})

View File

@@ -126,6 +126,9 @@ type PluralBaseResources = {
workflow: {
'changeHistory.stepBackward': string
'changeHistory.stepForward': string
'copilot.checklistFixAction': string
'copilot.checklistFixSummary': string
'copilot.checklistUnfixableNote': string
'nodes.iteration.error': string
'nodes.iteration.iteration': string
'nodes.loop.error': string

View File

@@ -281,6 +281,50 @@
"common.workflowProcessRunning": "Workflow Process running",
"common.workflowProcessSucceeded": "Workflow Process succeeded",
"common.workflowSaveStatus": "Workflow save status",
"copilot.actionApproveRepair": "Approve repair",
"copilot.actionProvideTestdata": "Provide mock test data",
"copilot.actionPublish": "Publish",
"copilot.actionReFix": "Re-fix",
"copilot.actionRunVerify": "Run verify",
"copilot.actionUndo": "Undo",
"copilot.actionsTitle": "Actions",
"copilot.appLabel": "App",
"copilot.buttonTooltip": "Workflow Copilot",
"copilot.checklistFixAction_one": "Fix {{count}} checklist issue",
"copilot.checklistFixAction_other": "Fix {{count}} checklist issues",
"copilot.checklistFixSummary_one": "{{count}} configuration issue can be auto-fixed",
"copilot.checklistFixSummary_other": "{{count}} configuration issues can be auto-fixed",
"copilot.checklistFixTitle": "Checklist issues",
"copilot.checklistRecheckAction": "Recheck checklist",
"copilot.checklistRecheckDescription": "The proposed fix was applied to the draft. Recheck the checklist to confirm it resolved the issue.",
"copilot.checklistRecheckTitle": "Checklist recheck",
"copilot.checklistUnfixableNote_one": "{{count}} issue can't be auto-fixed yet — resolve it manually before publishing",
"copilot.checklistUnfixableNote_other": "{{count}} issues can't be auto-fixed yet — resolve them manually before publishing",
"copilot.contextMissing": "Open this panel from an app's workflow editor to use Workflow Copilot.",
"copilot.errorPrefix": "Error",
"copilot.messagePlaceholder": "Send a message to the copilot",
"copilot.messageSend": "Send",
"copilot.panelDescription": "Diagnose and repair a failed workflow run with AI-assisted fixes.",
"copilot.panelTitle": "Workflow Copilot",
"copilot.progressEmpty": "Progress will appear here once a fix starts.",
"copilot.progressTitle": "Progress",
"copilot.runToFixAutoEmpty": "No failed run found yet. Enter a run ID manually.",
"copilot.runToFixAutoLabel": "Latest failed run",
"copilot.runToFixAutoLoading": "Looking for the latest failed run…",
"copilot.runToFixManualLabel": "Or enter a run ID",
"copilot.runToFixManualPlaceholder": "Workflow run ID",
"copilot.runToFixTitle": "Run to fix",
"copilot.sessionCanvasReadOnlyLabel": "Canvas read-only",
"copilot.sessionEmpty": "No active fix session yet.",
"copilot.sessionIdLabel": "Session",
"copilot.sessionInterruptedLabel": "Interrupted",
"copilot.sessionRunStatusLabel": "Run status",
"copilot.sessionStateLabel": "State",
"copilot.sessionVersionLabel": "Version",
"copilot.startFix": "Start Fix",
"copilot.starting": "Starting…",
"copilot.useThisRun": "Fix this run",
"copilot.workspaceLabel": "Workspace",
"customWebhook": "Custom Webhook",
"debug.copyLastRun": "Copy Last Run",
"debug.copyLastRunError": "Failed to copy last run inputs",