'use client'
import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen'
import type { Hotkey } from '@tanstack/react-hotkeys'
import type { SelectorParam, TFunction } from 'i18next'
import type { GeneratedGraph } from './types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type {
  GenerateWorkflowBody,
  GenerateWorkflowResponse as StreamResult,
  WorkflowGenPlan,
} from '@/service/workflow-generator'
import type { CompletionParams, ModelModeType } from '@/types/app'
import {
  AlertDialog,
  AlertDialogActions,
  AlertDialogCancelButton,
  AlertDialogConfirmButton,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { matchesKeyboardEvent } from '@tanstack/react-hotkeys'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import VersionSelector from '@/app/components/app/configuration/config/automatic/version-selector'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import WorkflowPreview from '@/app/components/workflow/workflow-preview'
import { WORKFLOW_GENERATION_TIMEOUT_MS } from '@/config'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useRouter } from '@/next/navigation'
import { fetchWorkflowDraft } from '@/service/workflow'
import { generateWorkflow, generateWorkflowStream } from '@/service/workflow-generator'
import { getRedirectionPath } from '@/utils/app-redirection'
import {
  applyToCurrentApp,
  applyToNewApp,
  WorkflowApplyHashCollisionError,
  WorkflowApplyOrphanError,
} from './apply'
import ExamplePrompts from './example-prompts'
import GenerationPlan from './generation-plan'
import { diffGraphs } from './graph-diff'
import {
  EMPTY_WORKFLOW_GENERATOR_MODEL,
  useWorkflowGeneratorLastInstruction,
  useWorkflowGeneratorModel,
} from './storage'
import { useWorkflowGeneratorStore } from './store'
import useGenGraph from './use-gen-graph'

// Mirrors the backend's instruction/ideal-output cap on /workflow-generate —
// keeping the limit client-side turns an opaque 400 into a visible input stop.
const MAX_INSTRUCTION_LENGTH = 10_000
const WORKFLOW_GENERATOR_SUBMIT_HOTKEY = 'Mod+Enter' satisfies Hotkey

// A single structured generation error. Mirrors the backend ``errors[]`` entry
// (stable ``code`` + human ``detail`` + optional ``node_id``) so the error panel
// can localise the message and point at the offending node.
type GenError = WorkflowGenerateErrorResponse
type WorkflowGeneratorErrorCode = WorkflowGenerateErrorResponse['code']

const workflowGeneratorErrorSelectors: Record<
  WorkflowGeneratorErrorCode,
  SelectorParam<'workflow'>
> = {
  DANGLING_EDGE: ($) => $['workflowGenerator.errors.DANGLING_EDGE'],
  DUPLICATE_NODE_ID: ($) => $['workflowGenerator.errors.DUPLICATE_NODE_ID'],
  EMPTY_INSTRUCTION: ($) => $['workflowGenerator.errors.EMPTY_INSTRUCTION'],
  EMPTY_PLAN: ($) => $['workflowGenerator.errors.EMPTY_PLAN'],
  GRAPH_CYCLE: ($) => $['workflowGenerator.errors.GRAPH_CYCLE'],
  INSTRUCTION_TOO_LONG: ($) => $['workflowGenerator.errors.INSTRUCTION_TOO_LONG'],
  INVALID_CONTAINER: ($) => $['workflowGenerator.errors.INVALID_CONTAINER'],
  INVALID_JSON: ($) => $['workflowGenerator.errors.INVALID_JSON'],
  INVALID_SCHEMA: ($) => $['workflowGenerator.errors.INVALID_SCHEMA'],
  MISSING_START: ($) => $['workflowGenerator.errors.MISSING_START'],
  MISSING_TERMINAL: ($) => $['workflowGenerator.errors.MISSING_TERMINAL'],
  MODEL_ERROR: ($) => $['workflowGenerator.errors.MODEL_ERROR'],
  UNKNOWN_NODE_REFERENCE: ($) => $['workflowGenerator.errors.UNKNOWN_NODE_REFERENCE'],
  UNKNOWN_TOOL: ($) => $['workflowGenerator.errors.UNKNOWN_TOOL'],
  UNRESOLVED_REFERENCE: ($) => $['workflowGenerator.errors.UNRESOLVED_REFERENCE'],
}

function getWorkflowGeneratorErrorMessage(error: GenError, t: TFunction<'workflow'>) {
  return t(workflowGeneratorErrorSelectors[error.code])
}

const renderPlaceholder = (label: string) => (
  <div className="flex min-h-0 w-full grow flex-col items-center justify-center space-y-3 px-8 md:w-0">
    <span
      aria-hidden="true"
      className="i-custom-vender-other-generator size-8 text-text-quaternary"
    />
    <div className="text-center text-[13px] leading-5 font-normal text-text-tertiary">{label}</div>
  </div>
)

// AbortController throws a DOMException in modern browsers and a plain
// Error in older / non-DOM environments — accept both so we don't toast
// for an abort the user intentionally triggered.
const isAbortError = (e: unknown): boolean =>
  (e instanceof DOMException || e instanceof Error) && e.name === 'AbortError'

type RecoveryDialogProps = {
  open: boolean
  onOpenChange: (open: boolean) => void
  title: string
  description: string
  cancelLabel: string
  confirmLabel: string
  onConfirm: () => void
}

// Shared shell for "we hit a snag — here's a Reload / Confirm button"
// dialogs. The overwrite-confirm and hash-collision dialogs differ only in
// copy and confirm handler; this collapses 30 lines of duplicate JSX to
// one props bag and keeps the visual styling in lockstep across both.
const RecoveryDialog = ({
  open,
  onOpenChange,
  title,
  description,
  cancelLabel,
  confirmLabel,
  onConfirm,
}: RecoveryDialogProps) => (
  <AlertDialog open={open} onOpenChange={(o) => !o && onOpenChange(false)}>
    <AlertDialogContent>
      <div className="flex flex-col gap-2 px-6 pt-6 pb-4">
        <AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
          {title}
        </AlertDialogTitle>
        <AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
          {description}
        </AlertDialogDescription>
      </div>
      <AlertDialogActions>
        <AlertDialogCancelButton>{cancelLabel}</AlertDialogCancelButton>
        <AlertDialogConfirmButton onClick={onConfirm}>{confirmLabel}</AlertDialogConfirmButton>
      </AlertDialogActions>
    </AlertDialogContent>
  </AlertDialog>
)

function WorkflowGeneratorModal() {
  const { t } = useTranslation('workflow')
  const router = useRouter()
  const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
  const isRbacEnabled = systemFeatures.rbac_enabled

  const isOpen = useWorkflowGeneratorStore((s) => s.isOpen)
  const mode = useWorkflowGeneratorStore((s) => s.mode)
  const intent = useWorkflowGeneratorStore((s) => s.intent)
  const currentAppId = useWorkflowGeneratorStore((s) => s.currentAppId)
  const currentAppMode = useWorkflowGeneratorStore((s) => s.currentAppMode)
  const initialInstruction = useWorkflowGeneratorStore((s) => s.initialInstruction)
  const autoMode = useWorkflowGeneratorStore((s) => s.autoMode)
  const closeGenerator = useWorkflowGeneratorStore((s) => s.closeGenerator)

  const isRefine = intent === 'refine' && !!currentAppId

  const [model, setModel] = useWorkflowGeneratorModel()

  const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(
    ModelTypeEnum.textGeneration,
  )

  // Hydrate model from defaultModel once it loads (async). We deliberately set state
  // from an effect here because defaultModel only resolves after the workspace's model
  // catalogue fetch completes.
  useEffect(() => {
    if (defaultModel && !model.name) {
      setModel((prev) => ({
        ...(prev ?? EMPTY_WORKFLOW_GENERATOR_MODEL),
        name: defaultModel.model,
        provider: defaultModel.provider.provider,
      }))
    }
  }, [defaultModel, model.name, setModel])

  const handleModelChange = useCallback(
    (newValue: { modelId: string; provider: string; mode?: string; features?: string[] }) => {
      setModel((prev) => ({
        ...(prev ?? EMPTY_WORKFLOW_GENERATOR_MODEL),
        provider: newValue.provider,
        name: newValue.modelId,
        mode: newValue.mode as ModelModeType,
      }))
    },
    [setModel],
  )

  const handleCompletionParamsChange = useCallback(
    (newParams: FormValue) => {
      setModel((prev) => ({
        ...(prev ?? EMPTY_WORKFLOW_GENERATOR_MODEL),
        completion_params: newParams as CompletionParams,
      }))
    },
    [setModel],
  )

  const [lastInstruction, setLastInstruction] = useWorkflowGeneratorLastInstruction()
  // Seed from the palette's inline-captured instruction, else the last instruction
  // generated from (persisted across opens). Captured at mount only — the modal
  // remounts on each open, so this is just the initial value.
  const [instruction, setInstruction] = useState(initialInstruction || lastInstruction || '')
  // Planner result, streamed ahead of the graph (null until it lands).
  const [plan, setPlan] = useState<WorkflowGenPlan | null>(null)
  // Structured generation errors (validation / model). Drives the actionable
  // error panel; null when the last attempt succeeded or hasn't run.
  const [genError, setGenError] = useState<GenError[] | null>(null)
  // Refine base graph captured at Generate time, diffed against the result so
  // the user can see what "apply" changes before overwriting their draft.
  const [refineBaseGraph, setRefineBaseGraph] = useState<GeneratedGraph | null>(null)

  const storageKey = `${mode}-${currentAppId ?? 'new'}`
  const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } =
    useGenGraph({
      storageKey,
    })

  const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false)
  const [isApplying, { setTrue: setApplyingTrue, setFalse: setApplyingFalse }] = useBoolean(false)

  // Confirmation dialog for "Apply to current draft"
  const [
    isShowConfirmOverwrite,
    { setTrue: showConfirmOverwrite, setFalse: hideConfirmOverwrite },
  ] = useBoolean(false)

  // Surfaced when the backend rejects the draft sync because another tab
  // edited the workspace after we fetched it. Dedicated dialog instead of a
  // toast because the user needs an explicit Reload action — without that,
  // a generic "apply failed" toast leaves them stuck and confused.
  const [isShowHashCollision, { setTrue: showHashCollision, setFalse: hideHashCollision }] =
    useBoolean(false)

  // Holds the AbortController of the in-flight ``/workflow-generate`` request
  // so we can cancel it on (a) modal close, (b) a second Generate click
  // while loading, (c) the hard frontend timeout, or (d) the user
  // pressing Cancel. Without this an in-flight request outlives the modal
  // and can race a future Generate call.
  const abortRef = useRef<AbortController | null>(null)
  // Companion timer so the timeout doesn't keep running after the response
  // lands. Cleared inside the same ``finally`` block that flips loading off.
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  // Mode the user generated against. If they switch app context mid-flight
  // (e.g. open the same modal from a different Studio in another tab) we
  // hide the "Apply to current" button so the wrong-mode graph never lands
  // in the wrong Studio. Captured at Generate time, not Apply time.
  const generatedModeRef = useRef<typeof mode | null>(null)

  const clearTimers = useCallback(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current)
      timeoutRef.current = null
    }
  }, [])

  const abortInFlight = useCallback(() => {
    if (abortRef.current) {
      abortRef.current.abort()
      abortRef.current = null
    }
    clearTimers()
  }, [clearTimers])

  // Cleanup on unmount — a modal unmount mid-generation must NOT leave the
  // request running in the background (it would still resolve, mutate the
  // store, and toast "applied" against a stale modal).
  useEffect(() => {
    return () => {
      abortInFlight()
    }
    // The cleanup function reads refs only, so it's stable; we intentionally
    // exclude ``abortInFlight`` from deps to avoid re-running this effect on
    // every render.
    // oxlint-disable-next-line react/exhaustive-deps
  }, [])

  // Note: the modal is mounted lazily by ``mount.tsx`` which unmounts it when
  // ``isOpen`` flips to false, so transient state (instruction / plan / errors)
  // resets implicitly on the next open. No reset effect needed.

  const isValid = () => {
    const trimmed = instruction.trim()
    if (!trimmed) {
      toast.error(t(($) => $['workflowGenerator.instructionRequired']))
      return false
    }
    if (!model.name) {
      // No usable model resolved (provider catalogue empty or still
      // loading). Without this guard the request would fly with an empty
      // ``model_config.name`` and surface as a backend 400 — not actionable
      // for the user. Tell them to pick a model.
      toast.error(t(($) => $['workflowGenerator.modelRequired']))
      return false
    }
    return true
  }

  // Apply a finished generation result (from the stream's ``result`` event or
  // the non-streaming fallback). Structured errors go to the actionable error
  // panel rather than a transient toast; a version is added only for a real graph.
  const handleResult = useCallback(
    (res: StreamResult) => {
      if (res.errors?.length) {
        setGenError(res.errors)
        return
      }
      if (!res.graph?.nodes?.length) {
        setGenError([
          {
            code: 'INVALID_SCHEMA',
            detail: res.error || t(($) => $['workflowGenerator.generateFailed']),
          },
        ])
        return
      }
      setGenError(null)
      addVersion(res)
    },
    [addVersion, t],
  )

  const onGenerate = async () => {
    if (!isValid()) return
    // Cancel any previous in-flight request (double-click guard).
    abortInFlight()

    generatedModeRef.current = mode
    setLastInstruction(instruction)
    setGenError(null)
    setPlan(null)
    setLoadingTrue()

    // Hard frontend timeout — aborts the request and surfaces a localised toast
    // instead of a perpetual spinner if the backend hangs. Generous on purpose
    // (NEXT_PUBLIC_WORKFLOW_GENERATION_TIMEOUT_MS, default 180s): the backend
    // runs a planner call plus parallel builder calls and may retry transient
    // errors, so aborting a slow-but-succeeding generation is the worse
    // failure mode.
    timeoutRef.current = setTimeout(() => {
      abortRef.current?.abort()
      abortRef.current = null
      toast.error(t(($) => $['workflowGenerator.errors.timeout']))
      setLoadingFalse()
    }, WORKFLOW_GENERATION_TIMEOUT_MS)

    // Refine mode: pull the current draft so the backend amends it instead of
    // starting from scratch. The modal mounts outside the Studio's ReactFlow
    // provider, so we read the persisted draft rather than the live canvas. A
    // fetch failure (no draft yet) degrades to from-scratch generation, but we
    // warn since the user explicitly asked to refine.
    let currentGraph: GeneratedGraph | undefined
    if (isRefine && currentAppId) {
      try {
        const draft = await fetchWorkflowDraft(`apps/${currentAppId}/workflows/draft`)
        if (draft?.graph?.nodes?.length) currentGraph = draft.graph as GeneratedGraph
      } catch {
        currentGraph = undefined
      }
      if (!currentGraph) toast.warning(t(($) => $['workflowGenerator.refineDraftUnavailable']))
    }
    setRefineBaseGraph(currentGraph ?? null)

    const body: GenerateWorkflowBody = {
      // Auto-mode sends 'auto' so the planner picks Workflow vs Chatflow; the
      // resolved concrete mode comes back on the result and drives apply.
      mode: autoMode ? 'auto' : mode,
      instruction,
      model_config: { ...model, mode: model.mode || 'chat' },
      ...(currentGraph
        ? { current_graph: currentGraph as unknown as GenerateWorkflowBody['current_graph'] }
        : {}),
    }

    const finish = () => {
      setLoadingFalse()
      clearTimers()
      abortRef.current = null
    }

    // Plan-first streaming: surface the planner outline the moment it lands, then
    // the graph. ``settled`` tracks whether the stream produced anything, so a
    // stream that dies before any event (endpoint missing, proxy buffering) can
    // fall back to the single-shot endpoint instead of failing the user.
    let settled = false
    generateWorkflowStream(body, {
      getAbortController: (c) => {
        abortRef.current = c
      },
      onPlan: (p) => {
        settled = true
        setPlan(p)
      },
      onResult: (res) => {
        settled = true
        handleResult(res)
        finish()
      },
      onError: (msg) => {
        if (!settled) {
          generateWorkflow(body, {
            getAbortController: (c) => {
              abortRef.current = c
            },
          })
            .then((res) => handleResult(res))
            .catch((e: unknown) => {
              if (isAbortError(e)) return
              const message = e instanceof Error ? e.message : ''
              toast.error(message || t(($) => $['workflowGenerator.generateFailed']))
            })
            .finally(finish)
          return
        }
        if (msg) toast.error(msg)
        finish()
      },
      onCompleted: () => {
        if (settled) finish()
      },
    })
  }

  const onCancelGeneration = useCallback(() => {
    abortInFlight()
    setLoadingFalse()
  }, [abortInFlight, setLoadingFalse])

  // "Apply to current" is valid only when the visible graph was generated
  // for the app we'd be writing to. We require: a current app exists, its
  // mode matches the current modal mode, AND the last Generate (if any)
  // ran in this same mode — otherwise the user switched tabs mid-flight
  // and we'd be writing a workflow graph into a chatflow draft (or vice
  // versa). Falls back to "Create new app" only.
  const generatedMode = generatedModeRef.current
  const generatedModeMatches = generatedMode === null || generatedMode === mode
  const canApplyToCurrent = !!currentAppId && currentAppMode === mode && generatedModeMatches

  const handleApplyToNew = useCallback(async () => {
    if (!current?.graph || isApplying) return
    setApplyingTrue()
    try {
      const { appId, appMode, permissionKeys } = await applyToNewApp({
        // Resolved mode — when the request used auto-mode this is the concrete
        // type the planner picked, so the new app is created as the right kind.
        mode: current.mode ?? mode,
        graph: current.graph as GeneratedGraph,
        instruction,
        appName: current.app_name,
        icon: current.icon,
      })
      // Nudge the freshly-created Studio toward iterating with cmd+k /refine
      // instead of regenerating from scratch for a small tweak.
      toast.success(t(($) => $['workflowGenerator.appliedRefineHint']))
      closeGenerator()
      router.push(
        getRedirectionPath(
          { id: appId, mode: appMode, permission_keys: permissionKeys },
          { isRbacEnabled },
        ),
      )
    } catch (e: unknown) {
      if (e instanceof WorkflowApplyOrphanError) {
        // Sync failed AND we couldn't roll back. Route the user to /apps so
        // the orphan is still discoverable — they can delete it by hand.
        toast.error(t(($) => $['workflowGenerator.errors.apply_failed_orphan']))
        closeGenerator()
        router.push('/apps')
        return
      }
      const message = e instanceof Error ? e.message : ''
      toast.error(message || t(($) => $['workflowGenerator.applyFailed']))
    } finally {
      setApplyingFalse()
    }
  }, [
    current,
    instruction,
    mode,
    router,
    closeGenerator,
    t,
    isApplying,
    isRbacEnabled,
    setApplyingTrue,
    setApplyingFalse,
  ])

  const handleApplyToCurrentConfirmed = useCallback(async () => {
    if (!current?.graph || !currentAppId || isApplying) return
    hideConfirmOverwrite()
    setApplyingTrue()
    try {
      await applyToCurrentApp({ appId: currentAppId, graph: current.graph as GeneratedGraph })
      toast.success(t(($) => $['workflowGenerator.applied']))
      closeGenerator()
      // Hard reload the workflow page so the canvas picks up the new draft —
      // ``router.refresh()`` only revalidates server-rendered route data, and
      // the Studio canvas is hydrated client-side via react-query / zustand.
      if (typeof window !== 'undefined') window.location.reload()
    } catch (e: unknown) {
      if (e instanceof WorkflowApplyHashCollisionError) {
        // Another tab edited the draft after we fetched it. Show a
        // dedicated dialog with a Reload affordance instead of a generic
        // "apply failed" toast — the user needs to know what actually
        // happened so they can pick up the other tab's edits before
        // retrying.
        showHashCollision()
        return
      }
      const message = e instanceof Error ? e.message : ''
      toast.error(message || t(($) => $['workflowGenerator.applyFailed']))
    } finally {
      setApplyingFalse()
    }
  }, [
    current,
    currentAppId,
    hideConfirmOverwrite,
    closeGenerator,
    t,
    isApplying,
    setApplyingTrue,
    setApplyingFalse,
    showHashCollision,
  ])

  const modeLabel =
    mode === 'workflow'
      ? t(($) => $['workflowGenerator.modes.workflow'])
      : t(($) => $['workflowGenerator.modes.chatflow'])

  // Refine diff — what an "apply" would change vs. the draft we started from.
  const refineDiff = useMemo(() => {
    if (!isRefine || !refineBaseGraph || !current?.graph?.nodes?.length) return null
    return diffGraphs(refineBaseGraph, current.graph as GeneratedGraph)
  }, [isRefine, refineBaseGraph, current])
  const hasRefineChanges =
    !!refineDiff &&
    (refineDiff.added.length > 0 || refineDiff.removed.length > 0 || refineDiff.changed.length > 0)

  // Derived view of the last structured error for the actionable error panel.
  const firstGenError = genError?.[0]
  const genErrorMessage = firstGenError ? getWorkflowGeneratorErrorMessage(firstGenError, t) : ''
  const genErrorHasUnknownTool = !!genError?.some((e) => e.code === 'UNKNOWN_TOOL')

  return (
    <Dialog
      open={isOpen}
      onOpenChange={(open) => {
        if (!open) {
          // Cancel any in-flight request BEFORE closing the store — a
          // request that resolves after the modal closes would still toast
          // against the now-unmounted modal and pollute version history.
          abortInFlight()
          closeGenerator()
        }
      }}
    >
      <DialogContent className="h-[min(680px,calc(100dvh-2rem))] max-h-none! w-[calc(100vw-2rem)] max-w-[1140px]! min-w-0 overflow-hidden! border-none p-0! text-left align-middle">
        <div className="flex h-full min-h-0 flex-col md:flex-row">
          {/* Left pane: instructions + ideal output + model selector */}
          <div className="max-h-[55%] w-full shrink-0 overflow-y-auto border-b border-divider-regular p-6 md:h-full md:max-h-none md:w-1/2 md:border-r md:border-b-0 lg:w-[570px]">
            <div className="mb-5">
              <DialogTitle className="text-lg leading-[28px] font-bold text-text-primary">
                {isRefine
                  ? t(($) => $['workflowGenerator.refineTitle'], { mode: modeLabel })
                  : t(($) => $['workflowGenerator.title'], { mode: modeLabel })}
              </DialogTitle>
              <DialogDescription className="mt-1 text-[13px] font-normal text-text-tertiary">
                {isRefine
                  ? t(($) => $['workflowGenerator.refineDescription'])
                  : t(($) => $['workflowGenerator.description'])}
              </DialogDescription>
            </div>

            <div>
              <ModelParameterModal
                popupClassName="w-[min(520px,calc(100vw-2rem))]!"
                isAdvancedMode={true}
                provider={model.provider}
                completionParams={model.completion_params}
                modelId={model.name}
                setModel={handleModelChange}
                onCompletionParamsChange={handleCompletionParamsChange}
                hideDebugWithMultipleModel
              />
            </div>

            <Field className="mt-4 gap-0" name="workflow-generator-instruction">
              <FieldLabel className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">
                {t(($) => $['workflowGenerator.instruction'])}
              </FieldLabel>
              <Textarea
                // Autofocus is appropriate here: the modal's sole purpose is to
                // capture an instruction, so focusing it on open aids the flow.
                // oxlint-disable-next-line jsx-a11y/no-autofocus
                autoFocus
                className="h-[160px]"
                placeholder={
                  isRefine
                    ? t(($) => $['workflowGenerator.refineInstructionPlaceholder'])
                    : t(($) => $['workflowGenerator.instructionPlaceholder'])
                }
                value={instruction}
                onValueChange={setInstruction}
                onKeyDown={(e) => {
                  if (matchesKeyboardEvent(e.nativeEvent, WORKFLOW_GENERATOR_SUBMIT_HOTKEY)) {
                    e.preventDefault()
                    if (!isLoading) onGenerate()
                  }
                }}
                maxLength={MAX_INSTRUCTION_LENGTH}
                autoComplete="off"
              />

              {/* Example prompts are create-from-scratch starters ("Summarize a
                  URL"); they don't fit a refine-the-current-graph task, so hide
                  them in refine mode. */}
              {!isRefine && <ExamplePrompts mode={mode} onSelect={setInstruction} />}

              <div className="mt-7 flex justify-end space-x-2">
                <Button onClick={closeGenerator}>{t(($) => $['workflowGenerator.dismiss'])}</Button>
                {isLoading ? (
                  // Cancel surfaces the abort affordance during the 60 s
                  // window where the user might want to bail (slow
                  // model, wrong instruction, etc.). Hidden when idle so
                  // the row stays focused on the primary action.
                  <Button
                    className="flex space-x-1"
                    variant="secondary"
                    onClick={onCancelGeneration}
                  >
                    <span className="text-xs font-semibold">
                      {t(($) => $['workflowGenerator.cancel'])}
                    </span>
                  </Button>
                ) : (
                  <Button
                    className="flex space-x-1"
                    variant="primary"
                    onClick={onGenerate}
                    disabled={!model.name}
                  >
                    <span aria-hidden="true" className="i-custom-vender-other-generator size-4" />
                    <span className="text-xs font-semibold">
                      {t(($) => $['workflowGenerator.generate'])}
                    </span>
                  </Button>
                )}
              </div>
            </Field>
          </div>

          {/* Right pane: planning → graph result / actionable error / empty placeholder */}
          {isLoading ? (
            <GenerationPlan plan={plan} />
          ) : genError?.length ? (
            <div className="flex min-h-0 w-full grow flex-col items-center justify-center gap-4 px-8 md:w-0">
              <span
                aria-hidden="true"
                className="i-ri-error-warning-line size-8 text-text-quaternary"
              />
              <div className="text-center">
                <div className="system-md-medium text-text-secondary">{genErrorMessage}</div>
                {firstGenError?.node_id && (
                  <div className="mt-1 system-xs-regular text-text-tertiary">
                    {t(($) => $['workflowGenerator.errors.atNode'], {
                      node: firstGenError.node_id,
                    })}
                  </div>
                )}
              </div>
              <div className="flex items-center gap-2">
                <Button size="small" variant="primary" onClick={onGenerate} disabled={!model.name}>
                  {t(($) => $['workflowGenerator.regenerate'])}
                </Button>
                {genErrorHasUnknownTool && (
                  <Button
                    size="small"
                    variant="secondary"
                    onClick={() => {
                      closeGenerator()
                      router.push('/tools')
                    }}
                  >
                    {t(($) => $['workflowGenerator.errors.installTools'])}
                  </Button>
                )}
              </div>
            </div>
          ) : current?.graph?.nodes?.length ? (
            <div className="flex min-h-0 w-full grow flex-col bg-background-default-subtle p-6 md:w-0">
              {/* Planner-picked identity — surfaces the app_name + icon the
                          UI used to discard so the user sees what they'll create. */}
              {(current.icon || current.app_name) && (
                <div className="mb-2 flex items-center gap-2">
                  {current.icon && (
                    <span aria-hidden="true" className="text-lg leading-none">
                      {current.icon}
                    </span>
                  )}
                  {current.app_name && (
                    <span className="truncate text-sm font-semibold text-text-primary">
                      {current.app_name}
                    </span>
                  )}
                </div>
              )}
              <div className="mb-3 flex items-center justify-between">
                <VersionSelector
                  versionLen={versions?.length || 0}
                  value={currentVersionIndex || 0}
                  onChange={setCurrentVersionIndex}
                />
                <div className="flex items-center space-x-2">
                  {canApplyToCurrent ? (
                    // Studio button entry — overwrite the current draft
                    // is the only meaningful Apply action, so collapse
                    // the two buttons into one primary "Apply".
                    <Button
                      size="small"
                      variant="primary"
                      onClick={showConfirmOverwrite}
                      disabled={isApplying}
                    >
                      {t(($) => $['workflowGenerator.studioApply'])}
                    </Button>
                  ) : (
                    // cmd+k /create entry — no current-app context, so
                    // the only path is "Create new app".
                    <Button
                      size="small"
                      variant="primary"
                      onClick={handleApplyToNew}
                      disabled={isApplying}
                    >
                      {t(($) => $['workflowGenerator.applyToNew'])}
                    </Button>
                  )}
                </div>
              </div>
              <div className="relative w-full grow overflow-hidden rounded-2xl border border-divider-subtle bg-background-default">
                <WorkflowPreview
                  nodes={(current.graph as unknown as GeneratedGraph).nodes}
                  edges={(current.graph as unknown as GeneratedGraph).edges}
                  viewport={current.graph.viewport}
                  miniMapToRight
                />
              </div>
              {/* Refine diff — what an apply changes vs. the draft we started from. */}
              {hasRefineChanges && refineDiff && (
                <div className="mt-2 system-xs-regular text-text-tertiary">
                  {t(($) => $['workflowGenerator.diff.summary'], {
                    added: refineDiff.added.length,
                    removed: refineDiff.removed.length,
                    changed: refineDiff.changed.length,
                  })}
                </div>
              )}
              {current.message && (
                <div className="mt-2 system-xs-regular text-text-tertiary">{current.message}</div>
              )}
            </div>
          ) : (
            renderPlaceholder(t(($) => $['workflowGenerator.placeholder']))
          )}
        </div>

        <RecoveryDialog
          open={isShowConfirmOverwrite}
          onOpenChange={() => hideConfirmOverwrite()}
          title={t(($) => $['workflowGenerator.overwriteTitle'])}
          description={t(($) => $['workflowGenerator.overwriteMessage'])}
          cancelLabel={t(($) => $['operation.cancel'], { ns: 'common' })}
          confirmLabel={t(($) => $['operation.confirm'], { ns: 'common' })}
          onConfirm={handleApplyToCurrentConfirmed}
        />

        {/* Hash-collision recovery — surfaces when another tab edited the
            draft between our fetch and sync. Reload picks up those edits;
            Dismiss returns to the modal so the user can copy the generated
            graph manually before re-fetching. */}
        <RecoveryDialog
          open={isShowHashCollision}
          onOpenChange={() => hideHashCollision()}
          title={t(($) => $['workflowGenerator.errors.hash_collision_title'])}
          description={t(($) => $['workflowGenerator.errors.hash_collision'])}
          cancelLabel={t(($) => $['operation.cancel'], { ns: 'common' })}
          confirmLabel={t(($) => $['workflowGenerator.reload'])}
          onConfirm={() => {
            hideHashCollision()
            if (typeof window !== 'undefined') window.location.reload()
          }}
        />
      </DialogContent>
    </Dialog>
  )
}

export default WorkflowGeneratorModal
