"use client";

import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type ChangeEvent,
  type FocusEvent,
  type MouseEvent,
  type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useReactFlow, useViewport, ViewportPortal } from "@xyflow/react";
import { useWorkflowStore } from "@/store/workflowStore";
import {
  AspectRatio,
  EaseCurveNodeData,
  Generate3DNodeData,
  GenerateAudioNodeData,
  GenerateVideoNodeData,
  ImageInputNodeData,
  ImageGenerationCount,
  ModelType,
  NanoBananaNodeData,
  NodeType,
  Resolution,
  SelectedModel,
  VideoStitchNodeData,
  WorkflowNode,
  WorkflowNodeData,
} from "@/types";
import {
  createDefaultAudioModel,
  createNewApiWGDefaultImageModel,
  createNewApiWGDefaultVideoModel,
  createPopiserverDefaultImageModel,
  createPopiserverDefaultVideoModel,
} from "@/store/utils/defaultImageModel";
import { isPopiProviderMode } from "@/lib/providerMode";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { ProviderModel } from "@/lib/providers/types";
import { useI18n } from "@/i18n";
import { DEFAULT_NEWAPIWG_LLM_MODEL_ID, LLM_MODELS } from "@/lib/llmModels";
import {
  DEFAULT_VIDEO_DURATION_SECONDS,
  DEFAULT_VIDEO_RESOLUTION,
  getDefaultVideoSoundEnabled,
  modelSupportsVideoSound,
  normalizeVideoDurationSeconds,
  VIDEO_RESOLUTION_OPTIONS,
} from "@/utils/videoGenerationSettings";
import { defaultNodeDimensions } from "@/store/utils/nodeDefaults";

type ComposerMode = "empty-create" | "node-edit" | "process-node" | "unsupported-node" | "multi-select";
type ComposerCapability = "all" | "image" | "video" | "3d" | "audio";
type EditableNodeType = "nanoBanana" | "generateVideo" | "generateAudio";
type DraftField = "prompt" | "aspectRatio" | "resolution" | "selectedModel" | "inputImages" | "imageCount" | "parameters" | "durationSeconds" | "soundEnabled";
type VideoStitchLoopCount = 1 | 2 | 3;

interface ComposerContext {
  mode: ComposerMode;
  node: WorkflowNode | null;
  capability: ComposerCapability;
  nodeType: NodeType | null;
  selectedCount: number;
}

interface ComposerDraft {
  prompt: string;
  aspectRatio: AspectRatio;
  resolution: string;
  selectedModel: SelectedModel;
  inputImages: string[];
  imageCount: ImageGenerationCount;
  durationSeconds: number;
  soundEnabled?: boolean;
  parameters?: Record<string, unknown>;
}

interface ComposerAdapter<TData extends WorkflowNodeData> {
  nodeType: EditableNodeType;
  capability: Exclude<ComposerCapability, "all" | "3d">;
  defaultModel: () => SelectedModel;
  readDraft: (node: WorkflowNode) => ComposerDraft;
  buildPatch: (draft: ComposerDraft, dirtyFields: Set<DraftField>) => Partial<TData>;
}

const ASPECT_RATIOS: AspectRatio[] = ["1:1", "3:4", "4:3", "9:16", "16:9", "21:9"];
const RESOLUTIONS: Resolution[] = ["1K", "2K", "4K"];
const VIDEO_DURATION_QUICK_OPTIONS = [4, 5, 8, 10, 15];
const IMAGE_COUNTS: ImageGenerationCount[] = [1, 2, 4];
const MAX_REFERENCE_IMAGES = 4;
const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024;
const COMPOSER_VIEWPORT_MARGIN = 16;
const COMPOSER_EXPANDED_WIDTH = 640;
const COMPOSER_COLLAPSED_WIDTH = 560;
const COMPOSER_EXPANDED_HEIGHT_ESTIMATE = 300;
const COMPOSER_COLLAPSED_HEIGHT_ESTIMATE = 80;

const DEFAULT_IMAGE_MODEL = isPopiProviderMode()
  ? createPopiserverDefaultImageModel()
  : createNewApiWGDefaultImageModel();
const DEFAULT_VIDEO_MODEL = isPopiProviderMode()
  ? createPopiserverDefaultVideoModel()
  : createNewApiWGDefaultVideoModel();
const DEFAULT_AUDIO_MODEL = createDefaultAudioModel();
const DEFAULT_PROMPT_TEXT_MODEL: SelectedModel = {
  provider: isPopiProviderMode() ? "popiserver" : "newapiwg",
  modelId: DEFAULT_NEWAPIWG_LLM_MODEL_ID,
  displayName: (isPopiProviderMode() ? LLM_MODELS.popiserver : LLM_MODELS.newapiwg)
    .find((model) => model.value === DEFAULT_NEWAPIWG_LLM_MODEL_ID)?.label || DEFAULT_NEWAPIWG_LLM_MODEL_ID,
  capabilities: ["text-to-text"],
};

function createEmptyDraft(): ComposerDraft {
  return {
    prompt: "",
    aspectRatio: "16:9",
    resolution: "2K",
    selectedModel: DEFAULT_IMAGE_MODEL,
    inputImages: [],
    imageCount: 1,
    durationSeconds: DEFAULT_VIDEO_DURATION_SECONDS,
    soundEnabled: getDefaultVideoSoundEnabled(DEFAULT_VIDEO_MODEL),
    parameters: {},
  };
}

function buildReferenceOnlyPrompt(): string {
  return "基于输入图片生成，保持主体一致、画风一致、构图自然。";
}

function createPromptDraft(node: WorkflowNode): ComposerDraft {
  const data = node.data as { prompt?: unknown };
  return {
    ...createEmptyDraft(),
    prompt: typeof data.prompt === "string" ? data.prompt : "",
    selectedModel: DEFAULT_PROMPT_TEXT_MODEL,
  };
}

function getComposerModelLabel(model?: SelectedModel): string {
  if (!model) return "Select model";
  return model.displayName || model.modelId;
}

function getLegacyImageModel(model: SelectedModel): ModelType {
  if (
    model.provider === "gemini" &&
    (model.modelId === "nano-banana" || model.modelId === "nano-banana-pro" || model.modelId === "nano-banana-2")
  ) {
    return model.modelId;
  }
  return "nano-banana-pro";
}

function selectedModelFromProvider(model: ProviderModel): SelectedModel {
  return {
    provider: model.provider,
    modelId: model.id,
    displayName: model.name,
    capabilities: model.capabilities,
    pricing: model.pricing
      ? {
          type: model.pricing.type,
          amount: model.pricing.amount,
        }
      : undefined,
    metadata: model.metadata,
  };
}

function hasAnyCapability(model: SelectedModel, capabilities: string[]): boolean {
  return Boolean(model.capabilities?.some((capability) => capabilities.includes(capability)));
}

function modelSupportsCapability(model: SelectedModel, capability: ComposerCapability): boolean {
  if (capability === "all") return true;
  if (capability === "image") return hasAnyCapability(model, ["text-to-image", "image-to-image"]);
  if (capability === "video") return hasAnyCapability(model, ["text-to-video", "image-to-video", "video-to-video", "audio-to-video"]);
  if (capability === "3d") return hasAnyCapability(model, ["text-to-3d", "image-to-3d"]);
  return hasAnyCapability(model, ["text-to-audio"]);
}

function storedModelFitsCapability(model: SelectedModel, capability: ComposerCapability): boolean {
  if (capability === "all") return true;
  if (!model.capabilities || model.capabilities.length === 0) return true;
  return modelSupportsCapability(model, capability);
}

function inferCapabilityFromModel(model: SelectedModel): Exclude<ComposerCapability, "all"> | null {
  if (modelSupportsCapability(model, "video")) return "video";
  if (modelSupportsCapability(model, "3d")) return "3d";
  if (modelSupportsCapability(model, "audio")) return "audio";
  if (modelSupportsCapability(model, "image")) return "image";
  return null;
}

function inferNodeTypeFromModel(model: SelectedModel): NodeType | null {
  const capability = inferCapabilityFromModel(model);
  if (capability === "video") return "generateVideo";
  if (capability === "3d") return "generate3d";
  if (capability === "audio") return "generateAudio";
  if (capability === "image") return "nanoBanana";
  return null;
}

function hasTextValue(value: string | null | undefined): value is string {
  return typeof value === "string" && value.trim().length > 0;
}

function getNodeDisplayTitle(node: WorkflowNode | undefined): string {
  if (!node) return "Unknown";
  const data = node.data as { customTitle?: unknown; label?: unknown; inputPrompt?: unknown };
  if (typeof data.customTitle === "string" && data.customTitle.trim()) return data.customTitle.trim();
  if (typeof data.label === "string" && data.label.trim()) return data.label.trim();
  if (typeof data.inputPrompt === "string" && data.inputPrompt.trim()) return data.inputPrompt.trim().slice(0, 32);
  return node.type;
}

function nodeHasVideoOutput(node: WorkflowNode | undefined): boolean {
  if (!node) return false;
  const data = node.data as { outputVideo?: unknown; video?: unknown };
  return Boolean(data.outputVideo || data.video);
}

function formatBezierHandles(handles: [number, number, number, number]): string {
  return handles.map((value) => value.toFixed(2)).join(", ");
}

function buildBezierPreviewPath(handles: [number, number, number, number]): string {
  const [x1, y1, x2, y2] = handles;
  return `M 8 92 C ${8 + x1 * 84} ${92 - y1 * 84}, ${8 + x2 * 84} ${92 - y2 * 84}, 92 8`;
}

function getNumericSize(value: unknown): number | undefined {
  if (typeof value === "number" && Number.isFinite(value)) return value;
  if (typeof value === "string") {
    const parsed = Number.parseFloat(value);
    return Number.isFinite(parsed) ? parsed : undefined;
  }
  return undefined;
}

function getComposerNodeDimensions(node: WorkflowNode): { width: number; height: number } {
  const defaults = defaultNodeDimensions[node.type as NodeType] ?? { width: 300, height: 220 };
  return {
    width: node.measured?.width ?? getNumericSize(node.style?.width) ?? defaults.width,
    height: node.measured?.height ?? getNumericSize(node.style?.height) ?? defaults.height,
  };
}

function readImageFileAsDataUrl(file: File): Promise<string | null> {
  if (!file.type.match(/^image\/(png|jpeg|webp)$/)) return Promise.resolve(null);
  if (file.size > MAX_REFERENCE_IMAGE_BYTES) return Promise.resolve(null);

  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = (event) => resolve((event.target?.result as string | undefined) ?? null);
    reader.onerror = () => resolve(null);
    reader.readAsDataURL(file);
  });
}

const imageComposerAdapter: ComposerAdapter<NanoBananaNodeData> = {
  nodeType: "nanoBanana",
  capability: "image",
  defaultModel: () => DEFAULT_IMAGE_MODEL,
  readDraft: (node) => {
    const data = node.data as NanoBananaNodeData;
    return {
      prompt: data.inputPrompt ?? "",
      aspectRatio: data.aspectRatio ?? "16:9",
      resolution: data.resolution ?? "2K",
      selectedModel: data.selectedModel ?? DEFAULT_IMAGE_MODEL,
      inputImages: data.inputImages ?? [],
      imageCount: data.imageCount ?? 1,
      durationSeconds: DEFAULT_VIDEO_DURATION_SECONDS,
      soundEnabled: undefined,
      parameters: data.parameters ?? {},
    };
  },
  buildPatch: (draft, dirtyFields) => {
    const patch: Partial<NanoBananaNodeData> = {};
    if (dirtyFields.has("prompt")) patch.inputPrompt = draft.prompt.trim();
    if (dirtyFields.has("aspectRatio")) patch.aspectRatio = draft.aspectRatio;
    if (dirtyFields.has("resolution")) patch.resolution = draft.resolution as Resolution;
    if (dirtyFields.has("inputImages")) patch.inputImages = draft.inputImages;
    if (dirtyFields.has("imageCount")) patch.imageCount = draft.imageCount;
    if (dirtyFields.has("selectedModel")) {
      patch.selectedModel = draft.selectedModel;
      patch.model = getLegacyImageModel(draft.selectedModel);
      patch.parameters = {};
      patch.fallbackParameters = {};
      patch.inputSchema = undefined;
    }
    return patch;
  },
};

const videoComposerAdapter: ComposerAdapter<GenerateVideoNodeData> = {
  nodeType: "generateVideo",
  capability: "video",
  defaultModel: () => DEFAULT_VIDEO_MODEL,
  readDraft: (node) => {
    const data = node.data as GenerateVideoNodeData;
    return {
      prompt: data.inputPrompt ?? "",
      aspectRatio: "16:9",
      resolution: data.resolution ?? DEFAULT_VIDEO_RESOLUTION,
      selectedModel: data.selectedModel ?? DEFAULT_VIDEO_MODEL,
      inputImages: data.inputImages ?? [],
      imageCount: 1,
      durationSeconds: data.durationSeconds ?? DEFAULT_VIDEO_DURATION_SECONDS,
      soundEnabled: data.soundEnabled ?? getDefaultVideoSoundEnabled(data.selectedModel ?? DEFAULT_VIDEO_MODEL),
      parameters: data.parameters ?? {},
    };
  },
  buildPatch: (draft, dirtyFields) => {
    const patch: Partial<GenerateVideoNodeData> = {};
    if (dirtyFields.has("prompt")) patch.inputPrompt = draft.prompt.trim();
    if (dirtyFields.has("resolution")) patch.resolution = draft.resolution;
    if (dirtyFields.has("durationSeconds")) patch.durationSeconds = normalizeVideoDurationSeconds(draft.durationSeconds);
    if (dirtyFields.has("soundEnabled")) patch.soundEnabled = draft.soundEnabled;
    if (dirtyFields.has("inputImages")) patch.inputImages = draft.inputImages;
    if (dirtyFields.has("selectedModel")) {
      patch.selectedModel = draft.selectedModel;
      patch.resolution = draft.resolution;
      patch.durationSeconds = normalizeVideoDurationSeconds(draft.durationSeconds);
      patch.soundEnabled = getDefaultVideoSoundEnabled(draft.selectedModel);
      patch.parameters = {};
      patch.fallbackParameters = {};
      patch.inputSchema = undefined;
    }
    return patch;
  },
};

const audioComposerAdapter: ComposerAdapter<GenerateAudioNodeData> = {
  nodeType: "generateAudio",
  capability: "audio",
  defaultModel: () => DEFAULT_AUDIO_MODEL,
  readDraft: (node) => {
    const data = node.data as GenerateAudioNodeData;
    return {
      prompt: data.inputPrompt ?? "",
      aspectRatio: "16:9",
      resolution: "2K",
      selectedModel: data.selectedModel ?? DEFAULT_AUDIO_MODEL,
      inputImages: [],
      imageCount: 1,
      durationSeconds: DEFAULT_VIDEO_DURATION_SECONDS,
      soundEnabled: undefined,
      parameters: data.parameters ?? {},
    };
  },
  buildPatch: (draft, dirtyFields) => {
    const patch: Partial<GenerateAudioNodeData> = {};
    if (dirtyFields.has("prompt")) patch.inputPrompt = draft.prompt.trim();
    if (dirtyFields.has("selectedModel")) {
      patch.selectedModel = draft.selectedModel;
      patch.parameters = {};
      patch.fallbackParameters = {};
      patch.inputSchema = undefined;
    }
    return patch;
  },
};

function getAdapter(nodeType: NodeType | null): ComposerAdapter<WorkflowNodeData> | null {
  if (!nodeType) return null;
  if (nodeType === "nanoBanana") return imageComposerAdapter as ComposerAdapter<WorkflowNodeData>;
  if (nodeType === "generateVideo") return videoComposerAdapter as ComposerAdapter<WorkflowNodeData>;
  if (nodeType === "generateAudio") return audioComposerAdapter as ComposerAdapter<WorkflowNodeData>;
  return null;
}

function deriveComposerContext(nodes: WorkflowNode[]): ComposerContext {
  const selectedNodes = nodes.filter((node) => node.selected);
  if (selectedNodes.length === 0) {
    return { mode: "empty-create", node: null, capability: "all", nodeType: null, selectedCount: 0 };
  }
  if (selectedNodes.length > 1) {
    return { mode: "multi-select", node: null, capability: "all", nodeType: null, selectedCount: selectedNodes.length };
  }

  const node = selectedNodes[0];
  const adapter = getAdapter(node.type);
  if (node.type === "videoStitch" || node.type === "easeCurve") {
    return { mode: "process-node", node, capability: "video", nodeType: node.type, selectedCount: 1 };
  }
  if (!adapter) {
    return { mode: "unsupported-node", node, capability: "all", nodeType: node.type, selectedCount: 1 };
  }

  return {
    mode: "node-edit",
    node,
    capability: adapter.capability,
    nodeType: adapter.nodeType,
    selectedCount: 1,
  };
}

function contextIdentity(context: ComposerContext): string {
  return `${context.mode}:${context.node?.id ?? "none"}:${context.nodeType ?? "none"}`;
}

function readDraftFromContext(context: ComposerContext): ComposerDraft {
  if (context.mode === "unsupported-node" && context.nodeType === "prompt" && context.node) {
    return createPromptDraft(context.node);
  }
  if (context.mode !== "node-edit") return createEmptyDraft();
  const adapter = getAdapter(context.nodeType);
  return adapter && context.node ? adapter.readDraft(context.node) : createEmptyDraft();
}

function nodeDraftFingerprint(context: ComposerContext): string {
  if (context.mode !== "node-edit" || !context.node) return contextIdentity(context);
  const data = context.node.data as NanoBananaNodeData | GenerateVideoNodeData | GenerateAudioNodeData;
  return JSON.stringify({
    identity: contextIdentity(context),
    inputPrompt: data.inputPrompt,
    inputImages: "inputImages" in data ? data.inputImages : undefined,
    aspectRatio: "aspectRatio" in data ? data.aspectRatio : undefined,
    resolution: "resolution" in data ? data.resolution : undefined,
    durationSeconds: "durationSeconds" in data ? data.durationSeconds : undefined,
    soundEnabled: "soundEnabled" in data ? data.soundEnabled : undefined,
    imageCount: "imageCount" in data ? data.imageCount : undefined,
    selectedModel: data.selectedModel
      ? {
          provider: data.selectedModel.provider,
          modelId: data.selectedModel.modelId,
          displayName: data.selectedModel.displayName,
          capabilities: data.selectedModel.capabilities,
        }
      : null,
    parameters: data.parameters,
  });
}

function buildInitialDataForNode(nodeType: NodeType, draft: ComposerDraft): Partial<WorkflowNodeData> {
  if (nodeType === "nanoBanana") {
    return {
      inputImages: draft.inputImages,
      inputPrompt: draft.prompt.trim(),
      aspectRatio: draft.aspectRatio,
      resolution: draft.resolution as Resolution,
      model: getLegacyImageModel(draft.selectedModel),
      selectedModel: draft.selectedModel,
      imageCount: draft.imageCount,
      parameters: {},
      inputSchema: undefined,
      useGoogleSearch: false,
      useImageSearch: false,
      status: "idle",
      error: null,
    } satisfies Partial<NanoBananaNodeData>;
  }
  if (nodeType === "generateVideo") {
    return {
      inputImages: draft.inputImages,
      inputPrompt: draft.prompt.trim(),
      selectedModel: draft.selectedModel,
      resolution: draft.resolution,
      durationSeconds: normalizeVideoDurationSeconds(draft.durationSeconds),
      soundEnabled: modelSupportsVideoSound(draft.selectedModel) ? draft.soundEnabled ?? true : undefined,
      parameters: {},
      inputSchema: undefined,
      status: "idle",
      error: null,
    } satisfies Partial<GenerateVideoNodeData>;
  }
  if (nodeType === "generate3d") {
    return {
      inputImages: draft.inputImages,
      inputPrompt: draft.prompt.trim(),
      selectedModel: draft.selectedModel,
      parameters: {},
      inputSchema: undefined,
      status: "idle",
      error: null,
    } satisfies Partial<Generate3DNodeData>;
  }
  if (nodeType === "generateAudio") {
    return {
      inputPrompt: draft.prompt.trim(),
      selectedModel: draft.selectedModel,
      parameters: {},
      inputSchema: undefined,
      status: "idle",
      error: null,
    } satisfies Partial<GenerateAudioNodeData>;
  }
  return {};
}

function IconButton({
  label,
  children,
  onClick,
}: {
  label: string;
  children: ReactNode;
  onClick?: () => void;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      aria-label={label}
      title={label}
      className="inline-flex h-8 w-8 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-700/80 hover:text-neutral-100"
    >
      {children}
    </button>
  );
}

export function GenerationComposer() {
  const { t } = useI18n();
  const nodes = useWorkflowStore((state) => state.nodes);
  const edges = useWorkflowStore((state) => state.edges);
  const dimmedNodeIds = useWorkflowStore((state) => state.dimmedNodeIds);
  const isRunning = useWorkflowStore((state) => state.isRunning);
  const isModalOpen = useWorkflowStore((state) => state.isModalOpen);
  const addNode = useWorkflowStore((state) => state.addNode);
  const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
  const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
  const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
  const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs);
  const { screenToFlowPosition } = useReactFlow();
  const { x: viewportX, y: viewportY, zoom } = useViewport();

  const context = useMemo(() => deriveComposerContext(nodes), [nodes]);
  const identity = contextIdentity(context);
  const fingerprint = nodeDraftFingerprint(context);
  const [draft, setDraft] = useState(() => readDraftFromContext(context));
  const [dirtyFields, setDirtyFields] = useState<Set<DraftField>>(() => new Set());
  const [assistMenu, setAssistMenu] = useState<"command" | "reference" | null>(null);
  const [modelDialogOpen, setModelDialogOpen] = useState(false);
  const [isComposerCollapsed, setIsComposerCollapsed] = useState(false);
  const [referencePreviewIndex, setReferencePreviewIndex] = useState<number | null>(null);
  const referenceImageInputRef = useRef<HTMLInputElement>(null);

  const contextRef = useRef(context);
  const draftRef = useRef(draft);
  const dirtyFieldsRef = useRef(dirtyFields);

  useEffect(() => {
    draftRef.current = draft;
  }, [draft]);

  useEffect(() => {
    dirtyFieldsRef.current = dirtyFields;
  }, [dirtyFields]);

  const clearDirtyFields = useCallback(() => {
    const empty = new Set<DraftField>();
    dirtyFieldsRef.current = empty;
    setDirtyFields(empty);
  }, []);

  const flushDraftForContext = useCallback(
    (targetContext: ComposerContext) => {
      const targetAdapter = getAdapter(targetContext.nodeType);
      const dirty = dirtyFieldsRef.current;
      if (targetContext.mode !== "node-edit" || !targetContext.node || !targetAdapter || dirty.size === 0) {
        return;
      }

      const nodeStillExists = useWorkflowStore.getState().nodes.some((node) => node.id === targetContext.node?.id);
      if (!nodeStillExists) {
        clearDirtyFields();
        return;
      }

      const patch = targetAdapter.buildPatch(draftRef.current, dirty);
      if (Object.keys(patch).length > 0) {
        updateNodeData(targetContext.node.id, patch);
      }
      clearDirtyFields();
    },
    [clearDirtyFields, updateNodeData]
  );

  const flushCurrentDraft = useCallback(() => {
    flushDraftForContext(contextRef.current);
  }, [flushDraftForContext]);

  useEffect(() => {
    const previousContext = contextRef.current;
    if (contextIdentity(previousContext) === identity) return;

    flushDraftForContext(previousContext);
    contextRef.current = context;
    setDraft(readDraftFromContext(context));
    clearDirtyFields();
  }, [clearDirtyFields, context, flushDraftForContext, identity]);

  useEffect(() => {
    if (context.mode !== "node-edit") return;
    const nextDraft = readDraftFromContext(context);
    setDraft((current) => {
      const dirty = dirtyFieldsRef.current;
      return {
        prompt: dirty.has("prompt") ? current.prompt : nextDraft.prompt,
        aspectRatio: dirty.has("aspectRatio") ? current.aspectRatio : nextDraft.aspectRatio,
        resolution: dirty.has("resolution") ? current.resolution : nextDraft.resolution,
        selectedModel: dirty.has("selectedModel") ? current.selectedModel : nextDraft.selectedModel,
        inputImages: dirty.has("inputImages") ? current.inputImages : nextDraft.inputImages,
        imageCount: dirty.has("imageCount") ? current.imageCount : nextDraft.imageCount,
        durationSeconds: dirty.has("durationSeconds") ? current.durationSeconds : nextDraft.durationSeconds,
        soundEnabled: dirty.has("soundEnabled") ? current.soundEnabled : nextDraft.soundEnabled,
        parameters: dirty.has("parameters") ? current.parameters : nextDraft.parameters,
      };
    });
  }, [context, fingerprint]);

  const connectedInputs = useMemo(() => {
    if ((context.mode !== "node-edit" && context.mode !== "process-node") || !context.node) return null;
    return getConnectedInputs(context.node.id);
  }, [context, getConnectedInputs, nodes, edges, dimmedNodeIds]);

  const isVideoStitchNode = context.mode === "process-node" && context.node?.type === "videoStitch";
  const isEaseCurveNode = context.mode === "process-node" && context.node?.type === "easeCurve";
  const isProcessNode = isVideoStitchNode || isEaseCurveNode;
  const videoStitchData = isVideoStitchNode && context.node ? (context.node.data as VideoStitchNodeData) : null;
  const easeCurveData = isEaseCurveNode && context.node ? (context.node.data as EaseCurveNodeData) : null;
  const processVideoInput = useMemo(() => {
    if (!isEaseCurveNode || !context.node) return null;
    const videoEdge = edges.find((edge) => edge.target === context.node?.id && edge.targetHandle === "video");
    const sourceNode = videoEdge ? nodes.find((node) => node.id === videoEdge.source) : undefined;
    return videoEdge
      ? {
          edgeId: videoEdge.id,
          title: getNodeDisplayTitle(sourceNode),
          hasVideo: nodeHasVideoOutput(sourceNode),
        }
      : null;
  }, [context.node, edges, isEaseCurveNode, nodes]);
  const videoStitchClipSummaries = useMemo(() => {
    if (!isVideoStitchNode || !context.node) return [];

    const stitchData = context.node.data as VideoStitchNodeData;
    const stitchEdges = edges.filter(
      (edge) => edge.target === context.node?.id && edge.targetHandle?.startsWith("video-")
    );
    const clipOrder = stitchData.clipOrder ?? [];

    const orderedEdges = [...stitchEdges].sort((a, b) => {
      const orderA = clipOrder.indexOf(a.id);
      const orderB = clipOrder.indexOf(b.id);
      if (orderA !== -1 && orderB !== -1) return orderA - orderB;
      if (orderA !== -1) return -1;
      if (orderB !== -1) return 1;
      return ((a.data as { createdAt?: number } | undefined)?.createdAt ?? 0) -
        ((b.data as { createdAt?: number } | undefined)?.createdAt ?? 0);
    });

    return orderedEdges.map((edge, index) => {
      const sourceNode = nodes.find((node) => node.id === edge.source);
      return {
        edgeId: edge.id,
        index: index + 1,
        title: getNodeDisplayTitle(sourceNode),
        handle: edge.targetHandle ?? `video-${index}`,
        hasVideo: nodeHasVideoOutput(sourceNode),
      };
    });
  }, [context.node, edges, isVideoStitchNode, nodes]);

  const connectedPrompt = hasTextValue(connectedInputs?.text) ? connectedInputs.text : null;
  const promptValue = connectedPrompt ?? draft.prompt;
  const promptReadOnly = Boolean(connectedPrompt);
  const activeCapability = context.mode === "empty-create"
    ? inferCapabilityFromModel(draft.selectedModel) ?? "image"
    : context.capability;
  const videoSoundSupported = activeCapability === "video" && modelSupportsVideoSound(draft.selectedModel);
  const videoDurationOptions = useMemo(() => {
    const duration = normalizeVideoDurationSeconds(draft.durationSeconds);
    return VIDEO_DURATION_QUICK_OPTIONS.includes(duration)
      ? VIDEO_DURATION_QUICK_OPTIONS
      : [...VIDEO_DURATION_QUICK_OPTIONS, duration].sort((a, b) => a - b);
  }, [draft.durationSeconds]);
  const canChooseModel = context.mode === "empty-create" || context.mode === "node-edit";
  const modelIsValid = context.mode === "empty-create"
    ? Boolean(inferNodeTypeFromModel(draft.selectedModel))
    : storedModelFitsCapability(draft.selectedModel, context.capability);
  const promptForSubmit = promptValue.trim();
  const selectedImageInputImage = context.node?.type === "imageInput"
    ? (context.node.data as ImageInputNodeData).image
    : null;
  const hasReferenceImageForSubmit =
    (context.mode === "empty-create" || context.mode === "node-edit") &&
    ((connectedInputs?.images.length ?? 0) > 0 || draft.inputImages.length > 0);
  const submitNodeType = context.mode === "empty-create"
    ? inferNodeTypeFromModel(draft.selectedModel)
    : context.nodeType;
  const hasRequiredPromptOrReference = submitNodeType === "generateAudio"
    ? promptForSubmit.length > 0
    : (promptForSubmit.length > 0 || hasReferenceImageForSubmit);
  const canCreateFromSelectedImageInput =
    context.mode === "unsupported-node" &&
    Boolean(selectedImageInputImage) &&
    !isRunning &&
    modelSupportsCapability(draft.selectedModel, "image");
  const canRunVideoStitch =
    isVideoStitchNode &&
    !isRunning &&
    videoStitchData?.encoderSupported !== false &&
    (connectedInputs?.videos.length ?? 0) >= 2;
  const canRunEaseCurve =
    isEaseCurveNode &&
    !isRunning &&
    easeCurveData?.encoderSupported !== false &&
    (connectedInputs?.videos.length ?? 0) >= 1;
  const canSubmit =
    canRunVideoStitch ||
    canRunEaseCurve ||
    (
      !isRunning &&
      (context.mode === "empty-create" || context.mode === "node-edit") &&
      hasRequiredPromptOrReference &&
      modelIsValid
    ) ||
    canCreateFromSelectedImageInput;

  const connectedReferenceImages = context.mode === "node-edit" ? connectedInputs?.images ?? [] : [];
  const referenceImages = connectedReferenceImages.length > 0 ? connectedReferenceImages : draft.inputImages;
  const primaryReference = referenceImages[0];
  const previewReferenceImage =
    referencePreviewIndex === null ? null : referenceImages[referencePreviewIndex] ?? null;
  const canEditReferenceImages =
    (context.mode === "empty-create" || context.mode === "node-edit") &&
    connectedReferenceImages.length === 0;
  const nodeData = context.node?.data as (NanoBananaNodeData | GenerateVideoNodeData | VideoStitchNodeData | EaseCurveNodeData | undefined);
  const errorMessage = nodeData?.status === "error" ? nodeData.error : null;
  const nodeEditModeLabel =
    context.nodeType === "nanoBanana"
      ? t("composer.imageNode")
      : context.nodeType === "generateVideo"
      ? t("composer.videoNode")
      : context.nodeType === "generateAudio"
      ? t("composer.audioNode")
      : t("composer.currentNode");
  const nodeEditGenerateTitle =
    context.nodeType === "nanoBanana"
      ? t("composer.regenerateImageNode")
      : context.nodeType === "generateVideo"
      ? t("composer.regenerateVideoNode")
      : context.nodeType === "generateAudio"
      ? t("composer.regenerateAudioNode")
      : t("composer.generate");
  const modelDialogTitle =
    context.nodeType === "nanoBanana"
      ? t("composer.chooseImageModel")
      : context.nodeType === "generateVideo"
      ? t("composer.chooseVideoModel")
      : context.nodeType === "generateAudio"
      ? t("composer.chooseAudioModel")
      : t("composer.chooseModelTitle");
  const modeLabel = context.mode === "empty-create"
    ? t("composer.newGeneration")
    : context.mode === "multi-select"
    ? t("composer.selectedNodes", { count: context.selectedCount })
    : context.mode === "unsupported-node"
    ? t("composer.currentNode")
    : context.mode === "process-node"
    ? t(context.nodeType === "easeCurve" ? "node.easeCurve" : "node.videoStitch")
    : nodeEditModeLabel;
  const generateTitle = context.mode === "empty-create"
    ? t("composer.createAndGenerate")
    : context.mode === "unsupported-node"
    ? t("composer.dragFromPort")
    : context.mode === "multi-select"
    ? t("composer.multiSelectNoSingleGenerate")
    : isVideoStitchNode
    ? t("composer.videoStitchRun")
    : isEaseCurveNode
    ? t("composer.easeCurveRun")
    : errorMessage
    ? t("composer.retryCurrentNode")
    : nodeEditGenerateTitle;

  const selectedNodeDimensions = useMemo(
    () => context.node ? getComposerNodeDimensions(context.node) : null,
    [context.node]
  );
  const safeZoom = zoom > 0 ? zoom : 1;
  const viewportWidth = typeof window === "undefined" ? Number.POSITIVE_INFINITY : window.innerWidth;
  const viewportHeight = typeof window === "undefined" ? Number.POSITIVE_INFINITY : window.innerHeight;
  const visibleLeft = -viewportX / safeZoom;
  const visibleRight = (viewportWidth - viewportX) / safeZoom;
  const visibleTop = -viewportY / safeZoom;
  const visibleBottom = (viewportHeight - viewportY) / safeZoom;
  const composerWidth = isComposerCollapsed ? COMPOSER_COLLAPSED_WIDTH : COMPOSER_EXPANDED_WIDTH;
  const composerFlowWidth = composerWidth / safeZoom;
  const composerHeightEstimate = isComposerCollapsed
    ? COMPOSER_COLLAPSED_HEIGHT_ESTIMATE
    : COMPOSER_EXPANDED_HEIGHT_ESTIMATE;
  const viewportMargin = COMPOSER_VIEWPORT_MARGIN / safeZoom;
  const inlineComposerPosition = context.node && selectedNodeDimensions
    ? (() => {
        const centeredLeft = context.node.position.x + selectedNodeDimensions.width / 2 - composerFlowWidth / 2;
        const minLeft = visibleLeft + viewportMargin;
        const maxLeft = visibleRight - composerFlowWidth - viewportMargin;
        const left = maxLeft >= minLeft
          ? Math.min(Math.max(centeredLeft, minLeft), maxLeft)
          : minLeft;
        const belowTop = context.node.position.y + selectedNodeDimensions.height + viewportMargin;
        const aboveTop = context.node.position.y - composerHeightEstimate / safeZoom - viewportMargin;
        const belowFits = belowTop + composerHeightEstimate / safeZoom <= visibleBottom - viewportMargin;
        const aboveFits = aboveTop >= visibleTop + viewportMargin;

        return {
          left,
          top: belowFits || !aboveFits ? belowTop : aboveTop,
        };
      })()
    : null;
  const shouldRenderInlineComposer =
    (context.mode === "node-edit" || context.mode === "process-node") &&
    Boolean(context.node && inlineComposerPosition);

  const markDraft = useCallback((patch: Partial<ComposerDraft>, fields: DraftField[]) => {
    setDraft((current) => {
      const next = { ...current, ...patch };
      draftRef.current = next;
      return next;
    });
    setDirtyFields((current) => {
      const next = new Set(current);
      fields.forEach((field) => next.add(field));
      dirtyFieldsRef.current = next;
      return next;
    });
  }, []);

  const updateVideoQuickDraft = useCallback(
    (patch: Partial<ComposerDraft>, fields: DraftField[]) => {
      const activeContext = contextRef.current;
      setDraft((current) => {
        const next = { ...current, ...patch };
        draftRef.current = next;
        return next;
      });

      if (activeContext.mode === "node-edit" && activeContext.node?.type === "generateVideo") {
        const nodePatch: Partial<GenerateVideoNodeData> = {};
        if (fields.includes("durationSeconds") && patch.durationSeconds !== undefined) {
          nodePatch.durationSeconds = normalizeVideoDurationSeconds(patch.durationSeconds);
        }
        if (fields.includes("resolution") && patch.resolution !== undefined) {
          nodePatch.resolution = patch.resolution;
        }
        if (fields.includes("soundEnabled") && Object.prototype.hasOwnProperty.call(patch, "soundEnabled")) {
          nodePatch.soundEnabled = patch.soundEnabled;
        }
        if (Object.keys(nodePatch).length > 0) {
          updateNodeData(activeContext.node.id, nodePatch);
        }
        setDirtyFields((current) => {
          const next = new Set(current);
          fields.forEach((field) => next.delete(field));
          dirtyFieldsRef.current = next;
          return next;
        });
        return;
      }

      setDirtyFields((current) => {
        const next = new Set(current);
        fields.forEach((field) => next.add(field));
        dirtyFieldsRef.current = next;
        return next;
      });
    },
    [updateNodeData]
  );

  const handleComposerBlur = useCallback(
    (event: FocusEvent<HTMLElement>) => {
      const nextTarget = event.relatedTarget as Node | null;
      if (nextTarget && event.currentTarget.contains(nextTarget)) return;
      flushCurrentDraft();
    },
    [flushCurrentDraft]
  );

  const stopCanvasEvent = useCallback((event: { stopPropagation: () => void; target?: EventTarget | null }) => {
    const target = event.target instanceof Element ? event.target : null;
    if (target?.closest("[data-composer-local-pointer='true']")) return;
    event.stopPropagation();
  }, []);

  const buildTargetPosition = useCallback(() => {
    return screenToFlowPosition({
      x: window.innerWidth / 2 - 150,
      y: window.innerHeight / 2 - 140,
    });
  }, [screenToFlowPosition]);

  const handleModelSelected = useCallback(
    (model: ProviderModel) => {
      const nextModel = selectedModelFromProvider(model);
      const activeContext = contextRef.current;

      if (activeContext.mode === "node-edit") {
        const activeAdapter = getAdapter(activeContext.nodeType);
        if (!activeContext.node || !activeAdapter || !modelSupportsCapability(nextModel, activeAdapter.capability)) {
          return;
        }
        const modelPatch = activeAdapter.buildPatch(
          {
            ...draftRef.current,
            selectedModel: nextModel,
            resolution: modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_RESOLUTION : draftRef.current.resolution,
            durationSeconds: modelSupportsCapability(nextModel, "video")
              ? DEFAULT_VIDEO_DURATION_SECONDS
              : draftRef.current.durationSeconds,
            soundEnabled: getDefaultVideoSoundEnabled(nextModel),
          },
          new Set<DraftField>(["selectedModel"])
        );
        updateNodeData(activeContext.node.id, modelPatch);
        setDraft((current) => ({
          ...current,
          selectedModel: nextModel,
          resolution: modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_RESOLUTION : current.resolution,
          durationSeconds: modelSupportsCapability(nextModel, "video")
            ? DEFAULT_VIDEO_DURATION_SECONDS
            : current.durationSeconds,
          soundEnabled: getDefaultVideoSoundEnabled(nextModel),
          parameters: {},
        }));
        clearDirtyFields();
        return;
      }

      if (activeContext.mode === "empty-create") {
        setDraft((current) => ({
          ...current,
          selectedModel: nextModel,
          resolution: modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_RESOLUTION : current.resolution,
          durationSeconds: modelSupportsCapability(nextModel, "video")
            ? DEFAULT_VIDEO_DURATION_SECONDS
            : current.durationSeconds,
          soundEnabled: getDefaultVideoSoundEnabled(nextModel),
          parameters: {},
        }));
      }
    },
    [clearDirtyFields, updateNodeData]
  );

  const handleReferenceImageChange = useCallback(
    async (event: ChangeEvent<HTMLInputElement>) => {
      if (!canEditReferenceImages) return;

      const files = Array.from(event.target.files ?? []);
      event.target.value = "";
      if (files.length === 0) return;

      const loadedImages = (await Promise.all(files.map(readImageFileAsDataUrl))).filter(
        (image): image is string => Boolean(image)
      );
      if (loadedImages.length === 0) return;

      markDraft(
        {
          inputImages: [...draftRef.current.inputImages, ...loadedImages].slice(0, MAX_REFERENCE_IMAGES),
        },
        ["inputImages"]
      );
    },
    [canEditReferenceImages, markDraft]
  );

  const removeReferenceImage = useCallback(
    (index: number) => {
      if (!canEditReferenceImages) return;
      markDraft(
        {
          inputImages: draftRef.current.inputImages.filter((_, imageIndex) => imageIndex !== index),
        },
        ["inputImages"]
      );
    },
    [canEditReferenceImages, markDraft]
  );

  useEffect(() => {
    if (referencePreviewIndex !== null && referencePreviewIndex >= referenceImages.length) {
      setReferencePreviewIndex(null);
    }
  }, [referenceImages.length, referencePreviewIndex]);

  useEffect(() => {
    if (!previewReferenceImage) return;
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        setReferencePreviewIndex(null);
      }
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [previewReferenceImage]);

  useEffect(() => {
    if (promptReadOnly) {
      setAssistMenu(null);
      return;
    }
    const lastChar = draft.prompt.at(-1);
    if (lastChar === "/") {
      setAssistMenu("command");
    } else if (lastChar === "@") {
      setAssistMenu("reference");
    } else if (!draft.prompt.includes("/") && !draft.prompt.includes("@")) {
      setAssistMenu(null);
    }
  }, [draft.prompt, promptReadOnly]);

  const handleSubmit = useCallback(async () => {
    if (!canSubmit) return;
    const fallbackPrompt = buildReferenceOnlyPrompt();
    const submitPrompt = promptForSubmit || fallbackPrompt;

    if (context.mode === "unsupported-node" && context.node?.type === "imageInput") {
      const imageData = context.node.data as ImageInputNodeData;
      const sourceImage = imageData.image;
      if (!sourceImage) return;
      const selectedModel = modelSupportsCapability(draftRef.current.selectedModel, "image")
        ? draftRef.current.selectedModel
        : DEFAULT_IMAGE_MODEL;
      const nextNodeId = addNode(
        "nanoBanana",
        buildTargetPosition(),
        buildInitialDataForNode("nanoBanana", {
          ...draftRef.current,
          selectedModel,
          inputImages: [sourceImage],
          prompt: submitPrompt,
        })
      );
      selectSingleNode(nextNodeId);
      setDraft(createEmptyDraft());
      clearDirtyFields();
      await regenerateNode(nextNodeId);
      return;
    }

    if (context.mode === "empty-create") {
      const nodeType = inferNodeTypeFromModel(draftRef.current.selectedModel);
      if (!nodeType) return;

      const nextNodeId = addNode(
        nodeType,
        buildTargetPosition(),
        buildInitialDataForNode(nodeType, {
          ...draftRef.current,
          prompt: submitPrompt,
        })
      );
      selectSingleNode(nextNodeId);
      setDraft(createEmptyDraft());
      clearDirtyFields();
      await regenerateNode(nextNodeId);
      return;
    }

    if (context.mode === "node-edit" && context.node) {
      if (!promptForSubmit && !promptReadOnly) {
        const nextDraft = { ...draftRef.current, prompt: submitPrompt };
        const nextDirtyFields = new Set(dirtyFieldsRef.current);
        nextDirtyFields.add("prompt");
        draftRef.current = nextDraft;
        dirtyFieldsRef.current = nextDirtyFields;
        setDraft(nextDraft);
        setDirtyFields(nextDirtyFields);
      }
      flushCurrentDraft();
      await regenerateNode(context.node.id);
      return;
    }

    if (
      context.mode === "process-node" &&
      (context.node?.type === "videoStitch" || context.node?.type === "easeCurve")
    ) {
      await regenerateNode(context.node.id);
    }
  }, [
    addNode,
    buildTargetPosition,
    canSubmit,
    clearDirtyFields,
    context,
    flushCurrentDraft,
    promptForSubmit,
    promptReadOnly,
    regenerateNode,
    selectSingleNode,
  ]);

  const insertCommand = useCallback((value: string) => {
    markDraft({ prompt: draftRef.current.prompt.replace(/\/$/, "") + value }, ["prompt"]);
    setAssistMenu(null);
  }, [markDraft]);

  const insertReference = useCallback((value: string) => {
    markDraft({ prompt: draftRef.current.prompt.replace(/@$/, "") + value }, ["prompt"]);
    setAssistMenu(null);
  }, [markDraft]);

  const toggleComposerCollapsed = useCallback(() => {
    if (!isComposerCollapsed) {
      flushCurrentDraft();
      setAssistMenu(null);
    }
    setIsComposerCollapsed((current) => !current);
  }, [flushCurrentDraft, isComposerCollapsed]);

  const expandComposer = useCallback(() => {
    setIsComposerCollapsed(false);
  }, []);

  const handleCollapsedComposerClick = useCallback(
    (event: MouseEvent<HTMLDivElement>) => {
      const target = event.target instanceof Element ? event.target : null;
      if (target?.closest("button,a,input,textarea,select,[role='button']")) return;
      expandComposer();
    },
    [expandComposer]
  );

  const collapsedStatus = useMemo(() => {
    if (isEaseCurveNode) {
      return (connectedInputs?.videos.length ?? 0) >= 1
        ? t("composer.easeCurveReady")
        : t("composer.easeCurveNoVideo");
    }
    if (isVideoStitchNode) {
      return (connectedInputs?.videos.length ?? 0) >= 2
        ? t("composer.videoStitchReady")
        : t("composer.videoStitchNeedTwo");
    }
    if (promptForSubmit) return promptForSubmit;
    if (context.mode === "unsupported-node") return t("composer.dragFromPort");
    if (context.mode === "multi-select") return t("composer.selectedNodes", { count: context.selectedCount });
    return t("composer.describeContent");
  }, [connectedInputs, context.mode, context.selectedCount, isEaseCurveNode, isVideoStitchNode, promptForSubmit, t]);

  const hideComposerForModal = isModalOpen && !modelDialogOpen;

  return (
    <>
      {!hideComposerForModal && shouldRenderInlineComposer && inlineComposerPosition && (
        <ViewportPortal>
          <div
            data-testid="node-inline-composer"
            className="pointer-events-none absolute"
            style={{
              left: `${inlineComposerPosition.left}px`,
              top: `${inlineComposerPosition.top}px`,
              zIndex: 10020,
            }}
          >
            <div
              className="pointer-events-none"
              style={{
                transform: `scale(${1 / safeZoom})`,
                transformOrigin: "top left",
              }}
            >
        <section
          data-testid="node-inline-composer-panel"
          className="nodrag nopan nowheel pointer-events-auto max-w-[calc(100vw-2rem)] rounded-2xl border border-neutral-700/80 bg-neutral-800/95 shadow-2xl shadow-black/35 backdrop-blur transition-[width] duration-200"
          style={{ width: `${composerWidth}px` }}
          onBlur={handleComposerBlur}
          onPointerDownCapture={stopCanvasEvent}
          onMouseDownCapture={stopCanvasEvent}
          onClick={stopCanvasEvent}
          onWheelCapture={stopCanvasEvent}
        >
          <input
            ref={referenceImageInputRef}
            type="file"
            accept="image/png,image/jpeg,image/webp"
            multiple
            aria-label={t("composer.uploadReferenceImage")}
            onChange={handleReferenceImageChange}
            className="hidden"
          />
          {isComposerCollapsed ? (
            <div className="flex h-16 cursor-pointer items-center gap-3 px-4" onClick={handleCollapsedComposerClick}>
              <div className="min-w-0 flex-1">
                <span className="flex min-w-0 items-center gap-2">
                  <button
                    type="button"
                    onClick={expandComposer}
                    title={t("composer.expand")}
                    className="shrink-0 rounded text-left text-[11px] text-neutral-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50"
                  >
                    {modeLabel}
                  </button>
                  <span className="h-3 w-px shrink-0 bg-neutral-700" />
                  {isProcessNode ? (
                    <button
                      type="button"
                      onClick={expandComposer}
                      title={t("composer.expand")}
                      className="min-w-0 truncate rounded text-left text-sm text-neutral-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50"
                    >
                      {collapsedStatus}
                    </button>
                  ) : (
                    <button
                      type="button"
                      onClick={() => canChooseModel && setModelDialogOpen(true)}
                      disabled={!canChooseModel}
                      aria-label={t("composer.selectModel")}
                      className="min-w-0 truncate rounded-md text-left text-sm text-neutral-100 transition-colors hover:text-cyan-200 disabled:text-neutral-500"
                      title={t("composer.selectModelTitle", { model: getComposerModelLabel(draft.selectedModel) })}
                    >
                      {getComposerModelLabel(draft.selectedModel)}
                    </button>
                  )}
                </span>
                {!isProcessNode && (
                  <button
                    type="button"
                    onClick={expandComposer}
                    title={t("composer.expand")}
                    className="mt-1 block max-w-full truncate rounded text-left text-xs text-neutral-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50"
                  >
                    {collapsedStatus}
                  </button>
                )}
              </div>

              <button
                type="button"
                onClick={expandComposer}
                aria-label={t("composer.expand")}
                title={t("composer.expand")}
                className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-neutral-700 bg-neutral-900/70 text-neutral-300 transition-colors hover:border-neutral-600 hover:text-neutral-100"
              >
                <svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
                  <path d="M7 17 17 7" />
                  <path d="M8 7h9v9" />
                </svg>
              </button>

              <button
                type="button"
                onClick={handleSubmit}
                disabled={!canSubmit}
                className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-neutral-200 text-neutral-950 transition-colors hover:bg-white disabled:cursor-not-allowed disabled:bg-neutral-600 disabled:text-neutral-400"
                aria-label={t("composer.generate")}
                title={generateTitle}
              >
                {isRunning ? (
                  <span className="h-4 w-4 animate-spin rounded-full border-2 border-neutral-500 border-t-transparent" />
                ) : (
                  <svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
                    <path d="M12 19V5" />
                    <path d="m5 12 7-7 7 7" />
                  </svg>
                )}
              </button>
            </div>
          ) : (
            <>
          <div className="flex items-center gap-2 px-4 pt-4">
            <div className="mr-1 flex h-14 min-w-24 flex-col justify-center rounded-lg border border-neutral-700 bg-neutral-900/60 px-3">
              <span className="text-[10px] text-neutral-500">{t("composer.console")}</span>
              <span className="text-xs font-medium text-neutral-100">{modeLabel}</span>
            </div>

            {isProcessNode ? (
              isVideoStitchNode ? (
                <div className="flex h-14 items-center gap-3 rounded-lg border border-neutral-700 bg-neutral-900/50 px-3 text-xs text-neutral-300">
                  <span>
                    {t("composer.videoStitchClipCount", { count: connectedInputs?.videos.length ?? 0 })}
                  </span>
                  <span className="h-4 w-px bg-neutral-700" />
                  <span className={connectedInputs?.audio.length ? "text-violet-200" : "text-neutral-500"}>
                    {connectedInputs?.audio.length ? t("composer.videoStitchAudio") : t("composer.videoStitchNoAudio")}
                  </span>
                </div>
              ) : (
                <div className="flex h-14 items-center gap-3 rounded-lg border border-neutral-700 bg-neutral-900/50 px-3 text-xs text-neutral-300">
                  <span>
                    {connectedInputs?.videos.length ? t("composer.easeCurveVideoConnected") : t("composer.easeCurveNoVideo")}
                  </span>
                  <span className="h-4 w-px bg-neutral-700" />
                  <span className={connectedInputs?.easeCurve ? "text-lime-200" : "text-neutral-500"}>
                    {connectedInputs?.easeCurve ? t("composer.easeCurveInherited") : t("composer.easeCurveLocal")}
                  </span>
                </div>
              )
            ) : (
              <>
                <button
                  type="button"
                  onClick={() => referenceImageInputRef.current?.click()}
                  disabled={!canEditReferenceImages || referenceImages.length >= MAX_REFERENCE_IMAGES}
                  title={canEditReferenceImages ? t("composer.addReferenceImage") : t("composer.referenceFromUpstream")}
                  className="flex h-14 w-16 flex-col items-center justify-center gap-1 rounded-lg border border-neutral-700 bg-neutral-800 text-xs text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-200 disabled:cursor-not-allowed disabled:opacity-50"
                >
                  <span className="text-base leading-none">+</span>
                  <span>{t("composer.reference")}</span>
                </button>

                {referenceImages.length > 0 && (
                  <div className="ml-1 flex max-w-[18rem] items-center gap-1.5 overflow-x-auto rounded-lg pr-1">
                    {referenceImages.map((referenceImage, imageIndex) => (
                      <div
                        key={`${imageIndex}-${referenceImage.slice(0, 24)}`}
                        className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-900"
                      >
                        <button
                          type="button"
                          aria-label={t("composer.referenceImage", { index: imageIndex + 1 })}
                          title={t("composer.referenceImage", { index: imageIndex + 1 })}
                          onClick={() => setReferencePreviewIndex(imageIndex)}
                          className="group h-full w-full overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70"
                        >
                          <img
                            src={referenceImage}
                            alt=""
                            className="h-full w-full object-cover transition-transform group-hover:scale-105"
                          />
                          <span className="absolute inset-0 flex items-center justify-center bg-black/0 text-white transition-colors group-hover:bg-black/20">
                            <svg
                              className="h-4 w-4 opacity-0 transition-opacity group-hover:opacity-90"
                              viewBox="0 0 24 24"
                              fill="none"
                              stroke="currentColor"
                              strokeWidth="2"
                            >
                              <path d="M15 3h6v6" />
                              <path d="M21 3l-7 7" />
                              <path d="M9 21H3v-6" />
                              <path d="M3 21l7-7" />
                            </svg>
                          </span>
                        </button>
                        <span className="pointer-events-none absolute left-1 top-1 rounded-full bg-neutral-900/80 px-1 text-[10px] text-neutral-200">
                          {imageIndex + 1}
                        </span>
                        {canEditReferenceImages && (
                          <button
                            type="button"
                            aria-label={t("composer.removeReferenceImage", { index: imageIndex + 1 })}
                            onClick={(event) => {
                              event.stopPropagation();
                              removeReferenceImage(imageIndex);
                            }}
                            className="absolute bottom-1 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-black/70 text-[10px] text-neutral-200 transition-colors hover:bg-red-600 hover:text-white"
                          >
                            ×
                          </button>
                        )}
                      </div>
                    ))}
                  </div>
                )}
              </>
            )}

            <div className="ml-auto flex items-center gap-1 text-neutral-500">
              <IconButton label={t("composer.collapse")} onClick={toggleComposerCollapsed}>
                <svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
                  <path d="M17 7 7 17" />
                  <path d="M16 17H7V8" />
                </svg>
              </IconButton>
            </div>
          </div>

          <div className="relative px-4 pt-3">
            {assistMenu && (
              <div className="absolute bottom-full left-4 mb-2 w-72 overflow-hidden rounded-xl border border-neutral-700 bg-neutral-800 shadow-2xl shadow-black/40">
                <div className="border-b border-neutral-700 px-3 py-2 text-[11px] font-medium text-neutral-500">
                  {assistMenu === "command" ? t("composer.commands") : t("composer.referenceMaterials")}
                </div>
                {assistMenu === "command" ? (
                  <div className="py-1">
                    {[
                      { label: t("composer.commandCharacter"), value: t("composer.commandCharacterValue") },
                      { label: t("composer.commandTurnaround"), value: t("composer.commandTurnaroundValue") },
                      { label: t("composer.commandVideoShot"), value: t("composer.commandVideoShotValue") },
                    ].map((item) => (
                      <button
                        key={item.label}
                        type="button"
                        onClick={() => insertCommand(item.value)}
                        className="flex w-full items-center justify-between px-3 py-2 text-left text-xs text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
                      >
                        <span>{item.label}</span>
                        <span className="text-neutral-500">/</span>
                      </button>
                    ))}
                  </div>
                ) : (
                  <div className="py-1">
                    {primaryReference ? (
                      <button
                        type="button"
                        onClick={() => insertReference(`@${t("composer.currentReference")} `)}
                        className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
                      >
                        <img src={primaryReference} alt="" className="h-8 w-8 rounded object-cover" />
                        <span>{t("composer.currentReference")}</span>
                      </button>
                    ) : (
                      <button
                        type="button"
                        onClick={() => insertReference(`@${t("composer.canvasAssets")} `)}
                        className="flex w-full items-center justify-between px-3 py-2 text-left text-xs text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
                      >
                        <span>{t("composer.canvasAssets")}</span>
                        <span className="text-neutral-500">@</span>
                      </button>
                    )}
                  </div>
                )}
              </div>
            )}

            {isVideoStitchNode ? (
              <div className="grid gap-3 md:grid-cols-[1fr_180px]">
                <div className="min-w-0 rounded-lg border border-neutral-700/70 bg-neutral-900/35 p-3">
                  <div className="flex items-center justify-between gap-3">
                    <span className="text-xs font-medium text-neutral-200">
                      {t("composer.videoStitchInputs")}
                    </span>
                    <span className="text-[11px] text-neutral-500">
                      {t("composer.videoStitchInputHint")}
                    </span>
                  </div>
                  {videoStitchClipSummaries.length === 0 ? (
                    <div className="mt-3 rounded-md border border-dashed border-neutral-700 px-3 py-5 text-center text-xs text-neutral-500">
                      {t("composer.videoStitchNeedTwo")}
                    </div>
                  ) : (
                    <div className="nowheel mt-3 grid max-h-28 grid-cols-2 gap-2 overflow-y-auto pr-1">
                      {videoStitchClipSummaries.map((clip) => (
                        <div
                          key={clip.edgeId}
                          className="flex min-w-0 items-center gap-2 rounded-md border border-neutral-700 bg-neutral-800/70 px-2 py-2"
                        >
                          <span className={`h-2 w-2 shrink-0 rounded-full ${clip.hasVideo ? "bg-pink-400" : "bg-neutral-600"}`} />
                          <div className="min-w-0">
                            <div className="truncate text-xs text-neutral-200">
                              {t("composer.videoStitchClip", { index: clip.index })}
                            </div>
                            <div className="truncate text-[11px] text-neutral-500">
                              {clip.title} · {clip.handle}
                            </div>
                          </div>
                        </div>
                      ))}
                    </div>
                  )}
                </div>

                <div className="rounded-lg border border-neutral-700/70 bg-neutral-900/35 p-3">
                  <div className="text-xs font-medium text-neutral-200">
                    {t("composer.videoStitchLoop")}
                  </div>
                  <div className="mt-2 grid grid-cols-3 gap-1">
                    {([1, 2, 3] as VideoStitchLoopCount[]).map((count) => (
                      <button
                        key={count}
                        type="button"
                        onClick={() => context.node && updateNodeData(context.node.id, { loopCount: count })}
                        className={`rounded-md px-2 py-1.5 text-xs transition-colors ${
                          videoStitchData?.loopCount === count
                            ? "bg-blue-600 text-white"
                            : "bg-neutral-800 text-neutral-400 hover:bg-neutral-700 hover:text-neutral-100"
                        }`}
                      >
                        {count}x
                      </button>
                    ))}
                  </div>
                  <div className="mt-3 rounded-md border border-neutral-700 bg-neutral-800/70 px-2 py-2 text-[11px] text-neutral-400">
                    {videoStitchData?.outputVideo
                      ? t("composer.videoStitchOutputReady")
                      : t("composer.videoStitchNoOutput")}
                  </div>
                </div>
              </div>
            ) : isEaseCurveNode ? (
              <div className="grid gap-3 md:grid-cols-[1fr_180px]">
                <div className="min-w-0 rounded-lg border border-neutral-700/70 bg-neutral-900/35 p-3">
                  <div className="flex items-center justify-between gap-3">
                    <span className="text-xs font-medium text-neutral-200">
                      {t("composer.easeCurveSettings")}
                    </span>
                    <span className="text-[11px] text-neutral-500">
                      {connectedInputs?.easeCurve ? t("composer.easeCurveInherited") : t("composer.easeCurveLocal")}
                    </span>
                  </div>
                  <div className="mt-3 grid gap-3 md:grid-cols-[120px_1fr]">
                    <div className="rounded-md border border-neutral-700 bg-neutral-950/60 p-2">
                      <svg viewBox="0 0 100 100" className="h-24 w-full">
                        <path d="M 8 92 L 92 8" stroke="rgb(64 64 64)" strokeWidth="2" strokeDasharray="5 5" fill="none" />
                        <path
                          d={buildBezierPreviewPath(easeCurveData?.bezierHandles ?? [0.42, 0, 0.58, 1])}
                          stroke="rgb(190 242 100)"
                          strokeWidth="4"
                          fill="none"
                          strokeLinecap="round"
                        />
                      </svg>
                    </div>
                    <div className="min-w-0 space-y-2">
                      <div className="rounded-md border border-neutral-700 bg-neutral-800/70 px-2 py-2">
                        <div className="text-[11px] text-neutral-500">{t("composer.easeCurvePreset")}</div>
                        <div className="truncate text-xs text-neutral-200">
                          {easeCurveData?.easingPreset ?? t("composer.easeCurveCustom")}
                        </div>
                      </div>
                      <div className="rounded-md border border-neutral-700 bg-neutral-800/70 px-2 py-2">
                        <div className="text-[11px] text-neutral-500">{t("composer.easeCurveHandles")}</div>
                        <div className="truncate font-mono text-[11px] text-neutral-300">
                          {formatBezierHandles(easeCurveData?.bezierHandles ?? [0.42, 0, 0.58, 1])}
                        </div>
                      </div>
                      <div className={`rounded-md border px-2 py-2 text-[11px] ${
                        processVideoInput?.hasVideo
                          ? "border-pink-500/30 bg-pink-500/10 text-pink-100"
                          : "border-neutral-700 bg-neutral-800/70 text-neutral-500"
                      }`}>
                        {processVideoInput?.hasVideo
                          ? `${t("composer.easeCurveInput")}: ${processVideoInput.title}`
                          : t("composer.easeCurveNoVideo")}
                      </div>
                    </div>
                  </div>
                </div>

                <div className="rounded-lg border border-neutral-700/70 bg-neutral-900/35 p-3">
                  <label className="text-xs font-medium text-neutral-200" htmlFor="composer-ease-duration">
                    {t("composer.easeCurveDuration")}
                  </label>
                  <input
                    id="composer-ease-duration"
                    type="number"
                    min="0.1"
                    max="30"
                    step="0.1"
                    value={easeCurveData?.outputDuration ?? 1.5}
                    onChange={(event) => {
                      if (!context.node) return;
                      const value = Number.parseFloat(event.target.value);
                      updateNodeData(context.node.id, {
                        outputDuration: Number.isFinite(value) ? Math.max(0.1, Math.min(30, value)) : 1.5,
                      });
                    }}
                    className="mt-2 w-full rounded-md border border-neutral-700 bg-neutral-800 px-2 py-1.5 text-xs text-neutral-100 outline-none focus:border-blue-500"
                  />
                  <div className="mt-3 rounded-md border border-neutral-700 bg-neutral-800/70 px-2 py-2 text-[11px] text-neutral-400">
                    {easeCurveData?.outputVideo
                      ? t("composer.easeCurveOutputReady")
                      : t("composer.easeCurveNoOutput")}
                  </div>
                </div>
              </div>
            ) : context.mode === "unsupported-node" || context.mode === "multi-select" ? (
              <div className="flex h-24 items-center rounded-lg text-sm text-neutral-400">
                {context.mode === "multi-select"
                  ? t("composer.multiSelectUnsupported", { count: context.selectedCount })
                  : t("composer.unsupportedNodeHint")}
              </div>
            ) : (
              <>
                <textarea
                  value={promptValue}
                  readOnly={promptReadOnly}
                  onChange={(event) => markDraft({ prompt: event.target.value }, ["prompt"])}
                  onKeyDown={(event) => {
                    if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
                      event.preventDefault();
                      handleSubmit();
                    }
                  }}
                  placeholder={t("composer.mainPlaceholder")}
                  className={`nowheel h-36 w-full resize-none rounded-lg bg-transparent text-sm leading-6 outline-none placeholder:text-neutral-500 ${
                    promptReadOnly ? "text-neutral-300" : "text-neutral-100"
                  }`}
                />
                {promptReadOnly && (
                  <div className="pb-2 text-[11px] text-cyan-200/80">
                    {t("composer.promptProvidedByUpstream")}
                  </div>
                )}
              </>
            )}
          </div>

          {errorMessage && (
            <div className="mx-4 mb-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-200">
              {errorMessage}
            </div>
          )}

          <div className="flex items-center gap-3 border-t border-neutral-700/70 px-4 py-3 text-sm text-neutral-300">
            {isProcessNode ? (
              <div className="flex min-w-0 items-center gap-2 text-neutral-400">
                <span className={isEaseCurveNode ? "text-lime-300" : "text-pink-300"}>●</span>
                <span className="truncate">
                  {isEaseCurveNode
                    ? ((connectedInputs?.videos.length ?? 0) >= 1
                      ? t("composer.easeCurveReady")
                      : t("composer.easeCurveNoVideo"))
                    : ((connectedInputs?.videos.length ?? 0) >= 2
                      ? t("composer.videoStitchReady")
                      : t("composer.videoStitchNeedTwo"))}
                </span>
              </div>
            ) : (
              <button
                type="button"
                onClick={() => canChooseModel && setModelDialogOpen(true)}
                disabled={!canChooseModel}
                aria-label={t("composer.selectModel")}
                className="flex items-center gap-2 rounded-md px-1.5 py-1 text-neutral-100 transition-colors hover:bg-neutral-700/70 disabled:cursor-not-allowed disabled:text-neutral-500"
                title={t("composer.selectModelTitle", { model: getComposerModelLabel(draft.selectedModel) })}
              >
                <span className="text-lg text-cyan-300">✦</span>
                <span>{getComposerModelLabel(draft.selectedModel)}</span>
                <span className="text-neutral-500">⌄</span>
              </button>
            )}

            {!isProcessNode && activeCapability === "image" && (
              <>
                <select
                  value={draft.aspectRatio}
                  onChange={(event) => markDraft({ aspectRatio: event.target.value as AspectRatio }, ["aspectRatio"])}
                  className="rounded-md border border-transparent bg-transparent px-1.5 py-1 text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60"
                >
                  {ASPECT_RATIOS.map((ratio) => (
                    <option key={ratio} value={ratio} className="bg-neutral-900">
                      {ratio}
                    </option>
                  ))}
                </select>

                <select
                  value={draft.resolution}
                  onChange={(event) => markDraft({ resolution: event.target.value as Resolution }, ["resolution"])}
                  className="rounded-md border border-transparent bg-transparent px-1.5 py-1 text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60"
                >
                  {RESOLUTIONS.map((item) => (
                    <option key={item} value={item} className="bg-neutral-900">
                      {item}
                    </option>
                  ))}
                </select>

                <select
                  aria-label={t("composer.imageCount")}
                  value={draft.imageCount}
                  onChange={(event) =>
                    markDraft({ imageCount: Number(event.target.value) as ImageGenerationCount }, ["imageCount"])
                  }
                  className="rounded-md border border-transparent bg-transparent px-1.5 py-1 text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60"
                >
                  {IMAGE_COUNTS.map((count) => (
                    <option key={count} value={count} className="bg-neutral-900">
                      {t("composer.imageCountOption", { count })}
                    </option>
                  ))}
                </select>
              </>
            )}

            {!isProcessNode && activeCapability === "video" && (
              <>
                <select
                  aria-label={t("node.duration")}
                  value={draft.durationSeconds}
                  onChange={(event) =>
                    updateVideoQuickDraft({ durationSeconds: normalizeVideoDurationSeconds(event.target.value) }, ["durationSeconds"])
                  }
                  className="rounded-md border border-transparent bg-transparent px-1.5 py-1 text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60"
                >
                  {videoDurationOptions.map((seconds) => (
                    <option key={seconds} value={seconds} className="bg-neutral-900">
                      {t("node.seconds", { count: seconds })}
                    </option>
                  ))}
                </select>

                <select
                  aria-label={t("node.resolution")}
                  value={draft.resolution}
                  onChange={(event) => updateVideoQuickDraft({ resolution: event.target.value }, ["resolution"])}
                  className="rounded-md border border-transparent bg-transparent px-1.5 py-1 text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60"
                >
                  {VIDEO_RESOLUTION_OPTIONS.map((item) => (
                    <option key={item} value={item} className="bg-neutral-900">
                      {item}
                    </option>
                  ))}
                </select>

                {videoSoundSupported && (
                  <button
                    type="button"
                    role="switch"
                    aria-checked={draft.soundEnabled ?? true}
                    onClick={() => updateVideoQuickDraft({ soundEnabled: !(draft.soundEnabled ?? true) }, ["soundEnabled"])}
                    className={`rounded-md px-1.5 py-1 transition-colors ${
                      (draft.soundEnabled ?? true)
                        ? "bg-cyan-500/15 text-cyan-200"
                        : "text-neutral-500 hover:bg-neutral-700/60 hover:text-neutral-300"
                    }`}
                  >
                    {t("node.sound")}
                  </button>
                )}
              </>
            )}

            <div className="ml-auto flex items-center gap-2">
              <button
                type="button"
                onClick={handleSubmit}
                disabled={!canSubmit}
                className="ml-1 flex h-9 w-9 items-center justify-center rounded-lg bg-neutral-200 text-neutral-950 transition-colors hover:bg-white disabled:cursor-not-allowed disabled:bg-neutral-600 disabled:text-neutral-400"
                aria-label={t("composer.generate")}
                title={generateTitle}
              >
                {isRunning ? (
                  <span className="h-4 w-4 animate-spin rounded-full border-2 border-neutral-500 border-t-transparent" />
                ) : (
                  <svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
                    <path d="M12 19V5" />
                    <path d="m5 12 7-7 7 7" />
                  </svg>
                )}
              </button>
            </div>
          </div>
            </>
          )}
        </section>
            </div>
          </div>
        </ViewportPortal>
      )}
      {modelDialogOpen && canChooseModel && (
        <ModelSearchDialog
          isOpen={modelDialogOpen}
          onClose={() => setModelDialogOpen(false)}
          initialProvider={draft.selectedModel.provider || "newapiwg"}
          initialCapabilityFilter={context.mode === "empty-create" ? "all" : context.capability}
          title={context.mode === "node-edit" ? modelDialogTitle : t("composer.chooseModelTitle")}
          onModelSelected={handleModelSelected}
        />
      )}
      {previewReferenceImage && typeof document !== "undefined" &&
        createPortal(
          <div
            role="dialog"
            aria-modal="true"
            aria-label={t("composer.referenceImage", { index: (referencePreviewIndex ?? 0) + 1 })}
            className="fixed inset-0 z-[120] flex items-center justify-center bg-black/90 p-6"
            onClick={() => setReferencePreviewIndex(null)}
          >
            <div className="relative max-h-full max-w-full" onClick={(event) => event.stopPropagation()}>
              <img
                src={previewReferenceImage}
                alt={t("composer.referenceImage", { index: (referencePreviewIndex ?? 0) + 1 })}
                className="max-h-[90vh] max-w-[92vw] rounded-lg object-contain shadow-2xl shadow-black"
              />
              <button
                type="button"
                onClick={() => setReferencePreviewIndex(null)}
                aria-label={t("common.close")}
                className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded bg-black/60 text-white transition-colors hover:bg-black/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70"
              >
                <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                  <path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
                </svg>
              </button>
            </div>
          </div>,
          document.body
        )}
    </>
  );
}
