<template>
  <div class="input-area compact-input-area" ref="inputAreaRoot">
    <div class="stadium-input-wrapper" ref="stadiumShellOuter">
      <div
        class="runtime-queue-list"
        :class="{ 'runtime-queue-list--empty': !runtimeQueuedMessagesForRender.length }"
        ref="runtimeQueueList"
      >
        <div
          v-for="(entry, index) in runtimeQueuedMessagesForRender"
          :key="entry.id"
          class="runtime-queue-item"
          :class="{
            'runtime-queue-item--top': index === 0
          }"
          :data-runtime-queue-id="entry.id || ''"
          :ref="bindRuntimeQueueItemRef(entry.id)"
        >
          <span class="runtime-queue-item__text">{{ entry.text || '' }}</span>
          <button
            type="button"
            class="runtime-queue-item__action"
            @click.stop="$emit('guide-runtime-message', entry.id)"
          >
            引导
          </button>
          <button
            type="button"
            class="runtime-queue-item__action runtime-queue-item__action--danger"
            @click.stop="$emit('delete-runtime-message', entry.id)"
          >
            删除
          </button>
        </div>
      </div>
      <transition
        name="skill-slash-menu-motion"
        @before-enter="handleSlashMenuTransitionStart"
        @before-leave="handleSlashMenuTransitionStart"
        @after-enter="handleSlashMenuAfterEnter"
        @after-leave="handleSlashMenuAfterLeave"
      >
        <div
          v-if="skillSlashMenuOpen"
          :key="slashMenuMode"
          ref="skillSlashList"
          class="skill-slash-menu"
          role="listbox"
          :aria-label="slashMenuAriaLabel"
        >
          <button
            v-for="(item, index) in activeSlashMenuItems"
            :key="item.id"
            type="button"
            class="skill-slash-item"
            :class="{
              'skill-slash-item--active': index === skillSlashActiveIndex,
              'skill-slash-item--disabled': item.disabled
            }"
            role="option"
            :aria-selected="index === skillSlashActiveIndex"
            :disabled="item.disabled"
            @mousedown.prevent="selectSlashMenuItem(index)"
          >
            <span class="skill-slash-item__name">{{ item.label }}</span>
            <span class="skill-slash-item__description">{{ item.description }}</span>
          </button>
          <div v-if="!activeSlashMenuItems.length" class="skill-slash-empty">
            {{ slashMenuEmptyText }}
          </div>
        </div>
      </transition>
      <transition name="floating-status-motion">
        <div
          v-if="floatingStatusVisible"
          class="floating-project-status"
          @click.stop
        >
          <span v-if="projectGitSummaryForRender" class="floating-project-status__left">
            <span class="floating-project-status__menu-wrap">
              <button
                type="button"
                class="floating-project-status__button floating-project-status__button--project"
                :class="{ 'floating-project-status__button--active': projectGitMenuOpen === 'project' }"
                @click.stop="toggleProjectGitMenu('project')"
              >
                {{ projectGitSummaryForRender.projectName }}
              </button>
              <div
                v-if="projectGitMenuOpen === 'project'"
                class="floating-project-status__submenu floating-project-status__submenu--project"
                @click.stop
              >
                <button type="button" @click.stop="openProjectInFileManager">在文件管理器中打开</button>
                <button type="button" @click.stop="copyProjectPath">复制地址</button>
              </div>
            </span>
            <span class="floating-project-status__menu-wrap">
              <button
                type="button"
                class="floating-project-status__button floating-project-status__button--branch"
                :class="{ 'floating-project-status__button--active': projectGitMenuOpen === 'branch' }"
                @click.stop="toggleProjectGitMenu('branch')"
              >
                {{ projectGitSummaryForRender.branch }}
              </button>
              <div
                v-if="projectGitMenuOpen === 'branch'"
                class="floating-project-status__submenu floating-project-status__submenu--branch"
                @click.stop
              >
                <button type="button" @click.stop="copyProjectBranch">复制分支名称</button>
              </div>
            </span>
          </span>
          <span class="floating-project-status__right">
            <span v-if="goalRunning" class="floating-project-status__notice">目标模式运行中</span>
            <span v-else-if="goalModeArmed" class="floating-project-status__notice">目标模式就绪</span>
            <button
              v-if="userQuestionMinimized && pendingUserQuestionCount > 0"
              type="button"
              class="floating-project-status__notice floating-project-status__notice--button"
              @click.stop="$emit('restore-user-question')"
            >
              等待回答问题
            </button>
            <button
              v-if="projectGitSummaryForRender"
              type="button"
              class="floating-project-status__stats"
              @click.stop="$emit('open-git-changes-panel')"
            >
              <span class="floating-project-status__add">+<RollingNumber :value="projectGitSummaryForRender.additions" /></span>
              <span class="floating-project-status__del">-<RollingNumber :value="projectGitSummaryForRender.deletions" /></span>
            </button>
          </span>
        </div>
      </transition>
      <div
        class="stadium-shell"
        ref="compactInputShell"
        :class="{
          'is-multiline': inputIsMultiline,
          'is-focused': inputIsFocused,
          'has-text': (inputMessage || '').trim().length > 0
        }"
      >
        <input
          type="file"
          ref="fileUploadInput"
          class="file-input-hidden"
          multiple
          @change="onFileChange"
        />
        <div class="input-stack">
          <div v-if="selectedImages && selectedImages.length" class="image-inline-row">
            <div class="image-thumbnail-wrapper" v-for="img in selectedImages" :key="img">
              <img :src="getPreviewUrl(img)" :alt="formatImageName(img)" class="image-thumbnail" />
              <button
                type="button"
                class="image-remove-btn-hover"
                @click.stop="$emit('remove-image', img)"
              >
                ×
              </button>
            </div>
          </div>
          <div
            v-if="selectedVideos && selectedVideos.length"
            class="image-inline-row video-inline-row"
          >
            <div class="image-thumbnail-wrapper" v-for="video in selectedVideos" :key="video">
              <img
                :src="getPreviewUrl(video)"
                :alt="formatImageName(video)"
                class="image-thumbnail"
              />
              <button
                type="button"
                class="image-remove-btn-hover"
                @click.stop="$emit('remove-video', video)"
              >
                ×
              </button>
            </div>
          </div>
          <div class="input-row">
            <button
              type="button"
              class="stadium-btn add-btn"
              data-tutorial="quick-menu-open"
              @click.stop="$emit('toggle-quick-menu')"
              :disabled="!isConnected"
            >
              +
            </button>
            <div class="stadium-input-rich">
              <EditorContent
                :editor="editor"
                class="stadium-input stadium-input-tiptap"
                :class="{ 'is-disabled': !isConnected || inputLocked }"
                @focusin="$emit('input-focus')"
                @focusout="onInputBlur"
              />
            </div>
            <button
              type="button"
              class="stadium-btn send-btn"
              @click="$emit('send-or-stop')"
              :disabled="sendButtonDisabled"
            >
              <span v-if="showStopIcon" class="stop-icon"></span>
              <span v-else class="send-icon"></span>
            </button>
          </div>
        </div>
      </div>
      <QuickMenu
        :open="quickMenuOpen"
        :is-connected="isConnected"
        :uploading="uploading"
        :streaming-message="streamingMessage"
        :thinking-mode="thinkingMode"
        :run-mode="runMode"
        :model-menu-open="modelMenuOpen"
        :model-options="modelOptions"
        :current-model-key="currentModelKey"
        :tool-menu-open="toolMenuOpen"
        :tool-settings="toolSettings"
        :tool-settings-loading="toolSettingsLoading"
        :settings-open="settingsOpen"
        :mode-menu-open="modeMenuOpen"
        :compressing="compressing"
        :current-conversation-id="currentConversationId"
        :icon-style="iconStyle"
        :tool-category-icon="toolCategoryIcon"
        :block-upload="blockUpload"
        :block-tool-toggle="blockToolToggle"
        :block-realtime-terminal="blockRealtimeTerminal"
        :block-focus-panel="blockFocusPanel"
        :block-token-panel="blockTokenPanel"
        :block-compress-conversation="blockCompressConversation"
        :block-conversation-review="blockConversationReview"
        :execution-mode-enabled="executionModeEnabled"
        :goal-mode-armed="goalModeArmed"
        :goal-running="goalRunning"
        @quick-upload="triggerQuickUpload"
        @pick-images="$emit('pick-images')"
        @pick-video="$emit('pick-video')"
        @toggle-tool-menu="$emit('toggle-tool-menu')"
        @toggle-settings="$emit('toggle-settings')"
        @toggle-mode-menu="$emit('toggle-mode-menu')"
        @select-run-mode="(mode) => $emit('select-run-mode', mode)"
        @toggle-model-menu="$emit('toggle-model-menu')"
        @select-model="(key) => $emit('select-model', key)"
        @update-tool-category="(id, enabled) => $emit('update-tool-category', id, enabled)"
        @realtime-terminal="$emit('realtime-terminal')"
        @toggle-focus-panel="$emit('toggle-focus-panel')"
        @toggle-token-panel="(val) => $emit('toggle-token-panel', val)"
        @compress-conversation="$emit('compress-conversation')"
        @toggle-approval-panel="$emit('toggle-approval-panel')"
        @open-review="$emit('open-review')"
        @open-path-authorization="$emit('open-path-authorization')"
        @toggle-goal-mode="$emit('toggle-goal-mode')"
      />
      <div class="permission-switcher" @click.stop>
        <button
          type="button"
          class="permission-switcher__btn"
          :disabled="!isConnected || streamingMessage"
          @click="$emit('open-versioning-dialog')"
        >
          <span>版本控制：{{ versioningEnabled ? '开启' : '关闭' }}</span>
        </button>
        <div class="permission-switcher__block">
          <button
            type="button"
            class="permission-switcher__btn"
            :disabled="!isConnected"
            @click="$emit('toggle-permission-menu')"
          >
            <span>权限：{{ currentPermissionLabel }}</span>
            <span class="permission-switcher__caret" :class="{ open: permissionMenuOpen }">›</span>
          </button>
          <div
            v-if="permissionMenuOpen"
            class="permission-switcher__menu"
            :class="{ 'permission-switcher__menu--split': executionModeEnabled }"
          >
            <div class="permission-switcher__group">
              <div class="permission-switcher__group-title">权限</div>
              <button
                v-for="option in permissionOptions"
                :key="`permission-${option.value}`"
                type="button"
                class="permission-switcher__item"
                :class="{ active: option.value === currentPermissionMode }"
                @click="$emit('change-permission-mode', option.value)"
              >
                <span class="permission-switcher__item-label">{{ option.label }}</span>
                <span class="permission-switcher__item-desc">{{ option.description }}</span>
              </button>
            </div>
            <div v-if="executionModeEnabled" class="permission-switcher__group">
              <div class="permission-switcher__group-title">执行环境</div>
              <button
                v-for="option in executionModeOptions"
                :key="`execution-${option.value}`"
                type="button"
                class="permission-switcher__item"
                :class="{ active: option.value === currentExecutionMode }"
                @click="$emit('change-execution-mode', option.value)"
              >
                <span
                  class="permission-switcher__item-label"
                  :class="{ 'permission-switcher__item-label--warn': option.value === 'direct' }"
                  >{{ option.label }}</span
                >
                <span class="permission-switcher__item-desc">{{ option.description }}</span>
              </button>
            </div>
          </div>
        </div>
      </div>
      <div class="context-usage-switcher" @click.stop>
        <button
          type="button"
          class="context-usage-switcher__btn"
          :disabled="!isConnected"
          @click="$emit('toggle-token-panel')"
        >
          <span
            class="context-usage-ring"
            :style="{
              '--context-progress': `${contextUsagePercent}%`,
              '--context-ring-color': contextUsageColor
            }"
            aria-hidden="true"
          >
            <span class="context-usage-ring__inner"></span>
          </span>
        </button>
        <div class="context-usage-switcher__tooltip">
          <div>{{ contextUsagePercentLabel }}已用</div>
          <div>{{ contextUsageCompactText }}</div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import {
  onMounted,
  onBeforeUnmount,
  onBeforeUpdate,
  onUpdated,
  ref,
  watch,
  nextTick,
  computed
} from 'vue';
import { EditorContent, useEditor, type JSONContent } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';
import Mention from '@tiptap/extension-mention';
import Placeholder from '@tiptap/extension-placeholder';
import { TextSelection } from 'prosemirror-state';
import QuickMenu from '@/components/input/QuickMenu.vue';
import RollingNumber from '@/components/input/RollingNumber.vue';
import { useInputStore } from '@/stores/input';
import { usePersonalizationStore } from '@/stores/personalization';

defineOptions({ name: 'InputComposer' });

const emit = defineEmits([
  'update:input-message',
  'input-change',
  'input-focus',
  'input-blur',
  'toggle-quick-menu',
  'send-message',
  'send-or-stop',
  'quick-upload',
  'pick-images',
  'pick-video',
  'toggle-tool-menu',
  'toggle-mode-menu',
  'toggle-model-menu',
  'select-run-mode',
  'select-model',
  'toggle-settings',
  'update-tool-category',
  'realtime-terminal',
  'toggle-focus-panel',
  'toggle-token-panel',
  'compress-conversation',
  'toggle-approval-panel',
  'file-selected',
  'remove-image',
  'remove-video',
  'open-review',
  'open-path-authorization',
  'toggle-permission-menu',
  'change-permission-mode',
  'change-execution-mode',
  'open-versioning-dialog',
  'guide-runtime-message',
  'delete-runtime-message',
  'composer-height-change',
  'restore-user-question',
  'toggle-goal-mode',
  'open-goal-dialog',
  'open-git-changes-panel'
]);

const props = defineProps<{
  inputMessage: string;
  inputIsMultiline: boolean;
  inputIsFocused: boolean;
  isConnected: boolean;
  streamingMessage: boolean;
  inputLocked: boolean;
  uploading: boolean;
  thinkingMode: boolean;
  runMode: 'fast' | 'thinking' | 'deep';
  quickMenuOpen: boolean;
  toolMenuOpen: boolean;
  modeMenuOpen: boolean;
  modelMenuOpen: boolean;
  toolSettings: Array<{ id: string; label: string; enabled: boolean }>;
  toolSettingsLoading: boolean;
  settingsOpen: boolean;
  compressing: boolean;
  currentConversationId: string | null;
  iconStyle: (key: string) => Record<string, string>;
  toolCategoryIcon: (categoryId: string) => string;
  modelOptions: Array<{
    key: string;
    label: string;
    description: string;
    disabled?: boolean;
    supportsImage?: boolean;
    supportsVideo?: boolean;
    contextWindow?: number | null;
  }>;
  currentModelKey: string;
  selectedImages?: string[];
  selectedVideos?: string[];
  mediaUploading?: boolean;
  blockUpload?: boolean;
  blockToolToggle?: boolean;
  blockRealtimeTerminal?: boolean;
  blockFocusPanel?: boolean;
  blockTokenPanel?: boolean;
  blockCompressConversation?: boolean;
  blockConversationReview?: boolean;
  currentPermissionMode: 'readonly' | 'approval' | 'auto_approval' | 'unrestricted';
  permissionMenuOpen: boolean;
  permissionOptions: Array<{ value: string; label: string; description: string }>;
  executionModeEnabled?: boolean;
  currentExecutionMode?: 'sandbox' | 'direct';
  executionModeOptions?: Array<{ value: string; label: string; description: string }>;
  currentContextTokens: number;
  versioningEnabled?: boolean;
  runtimeQueuedMessages?: Array<{ id: string; text: string }>;
  projectGitSummary?: {
    has_git?: boolean;
    project_name?: string;
    project_path?: string;
    branch?: string;
    additions?: number;
    deletions?: number;
  } | null;
  userQuestionMinimized?: boolean;
  pendingUserQuestionCount?: number;
  goalModeArmed?: boolean;
  goalRunning?: boolean;
  goalProgress?: Record<string, any> | null;
}>();

const inputStore = useInputStore();
const personalizationStore = usePersonalizationStore();
const inputAreaRoot = ref<HTMLElement | null>(null);
const stadiumShellOuter = ref<HTMLElement | null>(null);
const compactInputShell = ref<HTMLElement | null>(null);
const stadiumInput = ref<HTMLTextAreaElement | null>(null);
const fileUploadInput = ref<HTMLInputElement | null>(null);
const baselineComposerVisualHeight = ref<number>(0);
type SkillItem = { name: string; description: string; path: string };
type SlashMenuMode = 'root' | 'skills' | 'theme';
type SlashMenuItem = {
  id: string;
  label: string;
  description: string;
  disabled?: boolean;
  action?: () => void;
  mode?: SlashMenuMode;
  skill?: SkillItem;
};
const availableSkills = ref<SkillItem[]>([]);
const selectedSkillRefs = ref<SkillItem[]>([]);
const skillsLoaded = ref(false);
const skillsLoading = ref(false);
const skillSlashOpen = ref(false);
const skillSlashQuery = ref('');
const skillSlashActiveIndex = ref(0);
const skillSlashList = ref<HTMLElement | null>(null);
const slashMenuMode = ref<SlashMenuMode>('root');
const slashMenuParentIndex = ref(0);
const slashMenuTransitioning = ref(false);
let composerResizeObserver: ResizeObserver | null = null;
let syncingEditorFromProps = false;

const formatImageName = (path: string): string => {
  if (!path) return '';
  const parts = path.split(/[/\\]/);
  return parts[parts.length - 1] || path;
};

const getPreviewUrl = (path: string): string => {
  if (!path) return '';
  return `/api/gui/files/download?path=${encodeURIComponent(path)}`;
};

const applyLineMetrics = (lines: number, multiline: boolean) => {
  inputStore.setInputLineCount(lines);
  inputStore.setInputMultiline(multiline);
};

const composerInputKey = computed(
  () => `${props.currentConversationId || 'new'}:${props.isConnected ? 'online' : 'offline'}`
);

const runtimeQueuedMessagesForRender = computed(() => {
  const list = Array.isArray(props.runtimeQueuedMessages) ? props.runtimeQueuedMessages : [];
  return list
    .filter((item) => item && item.id && item.text)
    .map((item) => ({
      ...item,
      text: String(item.text || '').replace(/[\r\n\u2028\u2029]+/g, ' ')
    }));
});

const projectGitSummaryForRender = computed(() => {
  if (personalizationStore?.form?.show_git_status_bar === false) return null;
  const summary = props.projectGitSummary;
  if (!summary?.has_git) return null;
  const projectName = String(summary.project_name || '').trim();
  const branch = String(summary.branch || '').trim();
  if (!projectName || !branch) return null;
  return {
    projectName,
    projectPath: String(summary.project_path || '').trim(),
    branch,
    additions: Math.max(0, Number(summary.additions || 0)),
    deletions: Math.max(0, Number(summary.deletions || 0))
  };
});

const floatingStatusVisible = computed(() => {
  if (skillSlashMenuOpen.value || props.quickMenuOpen) return false;
  return (
    !!projectGitSummaryForRender.value ||
    !!props.goalRunning ||
    !!props.goalModeArmed ||
    (!!props.userQuestionMinimized && Number(props.pendingUserQuestionCount || 0) > 0)
  );
});

const projectGitMenuOpen = ref<null | 'project' | 'branch'>(null);

const closeProjectGitMenu = () => {
  projectGitMenuOpen.value = null;
};

const toggleProjectGitMenu = (menu: 'project' | 'branch') => {
  projectGitMenuOpen.value = projectGitMenuOpen.value === menu ? null : menu;
};

const copyTextToClipboard = async (text: string) => {
  const value = String(text || '').trim();
  if (!value || typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
    return;
  }
  await navigator.clipboard.writeText(value);
  closeProjectGitMenu();
};

const copyProjectPath = () => {
  copyTextToClipboard(projectGitSummaryForRender.value?.projectPath || '');
};

const copyProjectBranch = () => {
  copyTextToClipboard(projectGitSummaryForRender.value?.branch || '');
};

const openProjectInFileManager = async () => {
  try {
    await fetch('/api/project/open-in-file-manager', { method: 'POST' });
  } catch (error) {
    console.warn('打开项目文件管理器失败:', error);
  } finally {
    closeProjectGitMenu();
  }
};

const findSlashToken = () => {
  const instance = editor.value;
  if (!instance) return null;
  const { state } = instance;
  const { $from } = state.selection;
  const lineStart = $from.start();
  const beforeInLine = $from.parent.textBetween(0, $from.parentOffset, '\n', '\n');
  const match = /(^|[ \n])\/([^\s/]*)$/.exec(beforeInLine);
  if (!match) return null;
  const query = match[2] || '';
  const start = $from.pos - query.length - 1;
  const end = $from.pos;
  return {
    start,
    end,
    query
  };
};

const closeSlashMenu = () => {
  skillSlashOpen.value = false;
  skillSlashQuery.value = '';
  skillSlashActiveIndex.value = 0;
  slashMenuMode.value = 'root';
  slashMenuParentIndex.value = 0;
};

const deleteSlashToken = () => {
  const token = findSlashToken();
  const instance = editor.value;
  if (!token || !instance) {
    return;
  }
  const { state, view } = instance;
  let tr = state.tr.delete(token.start, token.end);
  tr = tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(0, token.start))));
  view.dispatch(tr);
  view.focus();
  nextTick(() => {
    adjustTextareaSize();
  });
};

const scrollSkillSlashSelectionIntoMiddle = () => {
  nextTick(() => {
    const list = skillSlashList.value;
    if (!list) return;
    const styles = getComputedStyle(list);
    const cssRowHeight = parseFloat(styles.getPropertyValue('--skill-slash-row-height'));
    const cssGap = parseFloat(styles.getPropertyValue('--skill-slash-gap'));
    const rowHeight = (Number.isFinite(cssRowHeight) ? cssRowHeight : 38) +
      (Number.isFinite(cssGap) ? cssGap : 5);
    const visibleRows = Math.max(1, Math.floor(list.clientHeight / rowHeight) || 5);
    const middleOffset = Math.floor(visibleRows / 2);
    const maxScroll = Math.max(0, list.scrollHeight - list.clientHeight);
    const target = Math.max(0, Math.min(maxScroll, (skillSlashActiveIndex.value - middleOffset) * rowHeight));
    list.scrollTop = target;
  });
};

const filteredSkills = computed(() => {
  const query = skillSlashQuery.value.trim().toLowerCase();
  const list = Array.isArray(availableSkills.value) ? availableSkills.value : [];
  const filtered = query
    ? list.filter((skill) => String(skill.name || '').toLowerCase().includes(query))
    : list;
  return filtered.slice(0, 100);
});

const currentModelSupportsImage = computed(() => {
  const found = props.modelOptions?.find((m) => m.key === props.currentModelKey) as any;
  return !!found?.supportsImage;
});

const currentModelSupportsVideo = computed(() => {
  const found = props.modelOptions?.find((m) => m.key === props.currentModelKey) as any;
  return !!found?.supportsVideo;
});

const applySlashTheme = async (theme: 'classic' | 'light' | 'dark') => {
  personalizationStore.applyTheme(theme);
  personalizationStore.updateField({ key: 'theme', value: theme });
  try {
    const resp = await fetch('/api/personalization', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ theme })
    });
    const result = await resp.json().catch(() => ({}));
    if (!resp.ok || !result.success) {
      console.warn('保存主题到后端失败:', result?.error);
    }
  } catch (error) {
    console.warn('保存主题到后端失败:', error);
  }
};

const executeSlashAction = (action: () => void) => {
  deleteSlashToken();
  closeSlashMenu();
  action();
};

const rootSlashMenuItems = computed<SlashMenuItem[]>(() => {
  const items: SlashMenuItem[] = [
    {
      id: 'upload',
      label: '上传文件',
      description: props.uploading ? '上传中...' : '选择本地文件上传',
      disabled: !props.isConnected || props.uploading || props.blockUpload,
      action: () => emit('quick-upload')
    },
    {
      id: 'image',
      label: '发送图片',
      description: currentModelSupportsImage.value ? '选择图片并附加到消息' : '当前模型不支持图片',
      disabled: !props.isConnected || props.streamingMessage || !currentModelSupportsImage.value,
      action: () => emit('pick-images')
    },
    {
      id: 'video',
      label: '发送视频',
      description: currentModelSupportsVideo.value ? '选择视频并附加到消息' : '当前模型不支持视频',
      disabled: !props.isConnected || props.streamingMessage || !currentModelSupportsVideo.value,
      action: () => emit('pick-video')
    },
    {
      id: 'compress',
      label: '压缩对话',
      description: props.compressing ? '压缩中...' : '手动压缩当前对话上下文',
      disabled:
        !props.isConnected ||
        props.compressing ||
        props.streamingMessage ||
        props.blockCompressConversation,
      action: () => emit('compress-conversation')
    },
    {
      id: 'skills',
      label: '选择 AgentSkill',
      description: '插入一个 AgentSkill 引用',
      mode: 'skills'
    },
    {
      id: 'theme',
      label: '切换主题',
      description: '经典 / 明亮 / 夜间',
      mode: 'theme'
    },
    {
      id: 'review',
      label: '对话回顾',
      description: '打开当前对话回顾',
      disabled: !props.isConnected || props.streamingMessage || props.blockConversationReview,
      action: () => emit('open-review')
    },
    {
      id: 'tokens',
      label: '用量统计',
      description: '打开上下文与 token 用量面板',
      disabled: !props.currentConversationId || props.blockTokenPanel,
      action: () => emit('toggle-token-panel', true)
    },
    {
      id: 'goal',
      label: '目标模式',
      description: props.goalRunning ? '目标模式运行中' : props.goalModeArmed ? '目标模式已就绪' : '切换目标模式',
      disabled: !props.isConnected,
      action: () => emit('toggle-goal-mode')
    },
    {
      id: 'personalization',
      label: '个人设置',
      description: '打开个人空间设置',
      action: () => {
        void personalizationStore.openDrawer();
      }
    }
  ];
  const query = skillSlashQuery.value.trim().toLowerCase();
  if (!query) {
    return items;
  }
  return items.filter((item) => {
    const haystack = `${item.label} ${item.description} ${item.id}`.toLowerCase();
    return haystack.includes(query);
  });
});

const themeSlashMenuItems = computed<SlashMenuItem[]>(() => {
  const themes: Array<SlashMenuItem & { theme: 'classic' | 'light' | 'dark' }> = [
    {
      id: 'theme-classic',
      label: '经典',
      description: '米色质感，柔和高对比',
      theme: 'classic'
    },
    {
      id: 'theme-light',
      label: '明亮',
      description: '纯白底色 + 优雅灰，简洁清爽',
      theme: 'light'
    },
    {
      id: 'theme-dark',
      label: '夜间',
      description: '深灰 + 黑，低亮度显示',
      theme: 'dark'
    }
  ];
  return themes.map((item) => ({
    ...item,
    action: () => {
      void applySlashTheme(item.theme);
    }
  }));
});

const skillSlashMenuItems = computed<SlashMenuItem[]>(() =>
  filteredSkills.value.map((skill) => ({
    id: `skill:${skill.path}`,
    label: skill.name,
    description: skill.description,
    skill
  }))
);

const activeSlashMenuItems = computed<SlashMenuItem[]>(() => {
  if (slashMenuMode.value === 'skills') {
    return skillSlashMenuItems.value;
  }
  if (slashMenuMode.value === 'theme') {
    return themeSlashMenuItems.value;
  }
  return rootSlashMenuItems.value;
});

const slashMenuAriaLabel = computed(() => {
  if (slashMenuMode.value === 'skills') return '可用 AgentSkills';
  if (slashMenuMode.value === 'theme') return '主题选项';
  return '快捷指令';
});

const slashMenuEmptyText = computed(() => {
  if (slashMenuMode.value === 'skills') return skillsLoading.value ? '正在加载 skills...' : '没有匹配的 skill';
  return '没有匹配的指令';
});

const skillSlashMenuOpen = computed(() => skillSlashOpen.value && (skillsLoading.value || activeSlashMenuItems.value.length >= 0));

const loadSkills = async () => {
  if (skillsLoaded.value || skillsLoading.value) {
    return;
  }
  skillsLoading.value = true;
  try {
    const response = await fetch('/api/skills');
    const data = await response.json().catch(() => ({}));
    if (!response.ok || !data?.success) {
      throw new Error(data?.error || '加载 skills 失败');
    }
    availableSkills.value = Array.isArray(data.skills)
      ? data.skills
          .filter((item: any) => item && item.name && item.path)
          .map((item: any) => ({
            name: String(item.name || ''),
            description: String(item.description || ''),
            path: String(item.path || '')
          }))
      : [];
    skillsLoaded.value = true;
  } catch (error) {
    availableSkills.value = [];
    skillsLoaded.value = false;
    console.warn('[SkillSlash] 加载 skills 失败:', error);
  } finally {
    skillsLoading.value = false;
  }
};

const refreshSkillSlashState = () => {
  const token = findSlashToken();
  if (!token || !props.isConnected || props.inputLocked) {
    closeSlashMenu();
    return;
  }
  closeProjectGitMenu();
  skillSlashOpen.value = true;
  if (skillSlashQuery.value !== token.query) {
    skillSlashActiveIndex.value = 0;
  }
  skillSlashQuery.value = token.query;
  if (slashMenuMode.value === 'skills') {
    void loadSkills();
  }
  skillSlashActiveIndex.value = Math.min(skillSlashActiveIndex.value, Math.max(0, activeSlashMenuItems.value.length - 1));
  scrollSkillSlashSelectionIntoMiddle();
};

const selectSkillSlashItem = (index = skillSlashActiveIndex.value) => {
  const token = findSlashToken();
  const skill = filteredSkills.value[index];
  const instance = editor.value;
  if (!token || !skill || !instance) {
    return;
  }
  selectedSkillRefs.value = [
    ...selectedSkillRefs.value.filter((item) => item.path !== skill.path),
    skill
  ];
  const { state, view, schema } = instance;
  const mentionType = schema.nodes.mention;
  if (!mentionType) {
    return;
  }
  const mentionNode = mentionType.create({
    id: skill.path,
    label: skill.name,
    path: skill.path,
    description: skill.description || '',
    mentionSuggestionChar: ''
  });
  let tr = state.tr.delete(token.start, token.end);
  tr = tr.insert(token.start, mentionNode);
  const afterMention = token.start + mentionNode.nodeSize;
  tr = tr.insertText(' ', afterMention);
  tr = tr.setSelection(TextSelection.near(tr.doc.resolve(afterMention + 1)));
  view.dispatch(tr);
  view.focus();
  closeSlashMenu();
  nextTick(() => {
    adjustTextareaSize();
  });
};

const selectSlashMenuItem = (index = skillSlashActiveIndex.value) => {
  const item = activeSlashMenuItems.value[index];
  if (!item || item.disabled) {
    return;
  }
  if (item.mode) {
    slashMenuParentIndex.value = index;
    slashMenuMode.value = item.mode;
    skillSlashActiveIndex.value = 0;
    if (item.mode === 'skills') {
      void loadSkills();
    }
    scrollSkillSlashSelectionIntoMiddle();
    return;
  }
  if (item.skill) {
    const skillIndex = filteredSkills.value.findIndex((skill) => skill.path === item.skill?.path);
    selectSkillSlashItem(skillIndex >= 0 ? skillIndex : index);
    return;
  }
  if (item.action) {
    executeSlashAction(item.action);
  }
};

const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => {
  if (event.ctrlKey && event.key === 'Enter') {
    return false;
  }
  if (skillSlashOpen.value) {
    if (event.key === 'ArrowDown') {
      event.preventDefault();
      const count = activeSlashMenuItems.value.length;
      if (count > 0) {
        skillSlashActiveIndex.value = (skillSlashActiveIndex.value + 1) % count;
        scrollSkillSlashSelectionIntoMiddle();
      }
      return true;
    }
    if (event.key === 'ArrowUp') {
      event.preventDefault();
      const count = activeSlashMenuItems.value.length;
      if (count > 0) {
        skillSlashActiveIndex.value = (skillSlashActiveIndex.value - 1 + count) % count;
        scrollSkillSlashSelectionIntoMiddle();
      }
      return true;
    }
    if (event.key === 'Enter') {
      const count = activeSlashMenuItems.value.length;
      if (count > 0) {
        event.preventDefault();
        selectSlashMenuItem(skillSlashActiveIndex.value);
      }
      return true;
    }
    if (event.key === 'Escape') {
      event.preventDefault();
      if (slashMenuMode.value !== 'root') {
        slashMenuMode.value = 'root';
        skillSlashActiveIndex.value = Math.min(
          slashMenuParentIndex.value,
          Math.max(0, activeSlashMenuItems.value.length - 1)
        );
        scrollSkillSlashSelectionIntoMiddle();
      } else {
        closeSlashMenu();
      }
      return true;
    }
  }
  return false;
};

const onKeydown = (event: KeyboardEvent): boolean => {
  if (handleSlashMenuKeydown(event)) {
    return true;
  }
  requestAnimationFrame(refreshSkillSlashState);
  return false;
};

const onInputBlur = () => {
  emit('input-blur');
  window.setTimeout(() => {
    closeSlashMenu();
  }, 120);
};

const textToTiptapContent = (text = ''): JSONContent => {
  const lines = String(text || '').split('\n');
  return {
    type: 'doc',
    content: (lines.length ? lines : ['']).map((line) => ({
      type: 'paragraph',
      content: line ? [{ type: 'text', text: line }] : undefined
    }))
  };
};

const getEditorPlainText = () => {
  const instance = editor.value;
  if (!instance) return props.inputMessage || '';
  return instance.getText({ blockSeparator: '\n' } as any);
};

const editorJsonToMessage = (json?: JSONContent | null) => {
  const skillRefs: Array<{ name: string; path: string }> = [];
  const readInline = (node?: JSONContent): string => {
    if (!node) return '';
    if (node.type === 'text') return node.text || '';
    if (node.type === 'mention') {
      const attrs = node.attrs || {};
      const name = String(attrs.label || attrs.id || '').trim();
      const path = String(attrs.path || attrs.id || '').trim();
      if (name && path && !skillRefs.some((item) => item.path === path)) {
        skillRefs.push({ name, path });
      }
      return name && path ? `[$${name}](${path})` : name;
    }
    if (node.type === 'hardBreak') return '\n';
    return Array.isArray(node.content) ? node.content.map(readInline).join('') : '';
  };
  const blocks = Array.isArray(json?.content) ? json.content : [];
  const message = blocks.map((block) => readInline(block)).join('\n').trim();
  return { message, skillRefs };
};

const editor = useEditor({
  content: textToTiptapContent(props.inputMessage || ''),
  editable: props.isConnected && !props.inputLocked,
  extensions: [
    StarterKit.configure({
      codeBlock: false,
      heading: false,
      blockquote: false,
      bulletList: false,
      orderedList: false,
      listItem: false,
      horizontalRule: false
    }),
    Placeholder.configure({
      placeholder: '输入消息... (Ctrl+Enter 发送)'
    }),
    Mention.configure({
      HTMLAttributes: {
        class: 'skill-md-link'
      },
      deleteTriggerWithBackspace: true,
      renderText({ node }) {
        return String(node.attrs.label || node.attrs.id || '');
      },
      renderHTML({ node, HTMLAttributes }) {
        const attrs = HTMLAttributes || {};
        return [
          'span',
          {
            ...attrs,
            class: `${attrs.class || ''} skill-md-link`.trim(),
            'data-skill-path': node.attrs.path || node.attrs.id || ''
          },
          String(node.attrs.label || node.attrs.id || '')
        ];
      },
      suggestion: {
        char: '\u0000'
      } as any
    })
  ],
  editorProps: {
    attributes: {
      class: 'stadium-input-editor',
      'data-placeholder': '输入消息... (Ctrl+Enter 发送)'
    },
    handleKeyDown(_view, event) {
      if (event.ctrlKey && event.key === 'Enter') {
        event.preventDefault();
        emit('send-or-stop');
        return true;
      }
      return onKeydown(event);
    }
  },
  onUpdate() {
    if (syncingEditorFromProps) return;
    emit('update:input-message', getEditorPlainText());
    emit('input-change');
    nextTick(() => {
      adjustTextareaSize();
      refreshSkillSlashState();
    });
  },
  onFocus() {
    emit('input-focus');
  }
});

const RUNTIME_QUEUE_ANIM_DURATION = 300;
const RUNTIME_QUEUE_ANIM_EASING = 'cubic-bezier(0.25, 0.8, 0.25, 1)';
const runtimeQueueList = ref<HTMLElement | null>(null);
const runtimeQueueItemRefs = new Map<string, HTMLElement>();
const runtimeQueuePrevIds: string[] = [];
const runtimeQueueRenderedIds: string[] = [];
const runtimeQueuePrevRects = new Map<string, DOMRect>();
const runtimeQueuePrevElements = new Map<string, HTMLElement>();
const runtimeQueueGhosts = new Map<string, HTMLElement>();
const runtimeQueueActiveAnimations = new WeakMap<HTMLElement, Animation>();
const runtimeQueueFallbackTimers = new WeakMap<HTMLElement, number>();
const runtimeQueueTransitioning = ref(false);
let runtimeQueueAnimationReady = false;
let runtimeQueueTransitionTimer: number | null = null;

const bindRuntimeQueueItemRef = (id: string) => (el: Element | null) => {
  if (!id) {
    return;
  }
  if (el instanceof HTMLElement) {
    runtimeQueueItemRefs.set(id, el);
  } else {
    runtimeQueueItemRefs.delete(id);
  }
};

const stopRuntimeQueueAnimation = (el: HTMLElement) => {
  const anim = runtimeQueueActiveAnimations.get(el);
  if (anim) {
    try {
      anim.cancel();
    } catch {
      // ignore
    }
    runtimeQueueActiveAnimations.delete(el);
  }
  const timer = runtimeQueueFallbackTimers.get(el);
  if (timer) {
    clearTimeout(timer);
    runtimeQueueFallbackTimers.delete(el);
  }
  el.style.removeProperty('transition');
  el.style.removeProperty('transform');
  el.style.removeProperty('pointer-events');
};

const keepRuntimeQueueTransitionCollapsed = () => {
  runtimeQueueTransitioning.value = true;
  if (runtimeQueueTransitionTimer !== null) {
    window.clearTimeout(runtimeQueueTransitionTimer);
  }
  runtimeQueueTransitionTimer = window.setTimeout(() => {
    runtimeQueueTransitioning.value = false;
    runtimeQueueTransitionTimer = null;
    emitComposerHeight();
  }, RUNTIME_QUEUE_ANIM_DURATION + 40);
};

const playRuntimeQueueTransform = (
  el: HTMLElement,
  fromY: number,
  toY: number,
  done?: () => void
) => {
  if (Math.abs(fromY - toY) < 0.5) {
    stopRuntimeQueueAnimation(el);
    el.style.removeProperty('transition');
    el.style.removeProperty('transform');
    done?.();
    return;
  }
  keepRuntimeQueueTransitionCollapsed();
  stopRuntimeQueueAnimation(el);
  const from = `translateY(${fromY}px)`;
  const to = `translateY(${toY}px)`;
  const finish = () => {
    el.style.removeProperty('transition');
    el.style.removeProperty('transform');
    done?.();
  };

  el.style.transition = 'none';
  el.style.transform = from;

  if (typeof el.animate === 'function') {
    // 强制应用起始位移，避免首帧先闪现到终点
    void el.offsetHeight;
    const animation = el.animate([{ transform: from }, { transform: to }], {
      duration: RUNTIME_QUEUE_ANIM_DURATION,
      easing: RUNTIME_QUEUE_ANIM_EASING,
      fill: 'both'
    });
    runtimeQueueActiveAnimations.set(el, animation);
    animation.addEventListener(
      'finish',
      () => {
        if (runtimeQueueActiveAnimations.get(el) === animation) {
          runtimeQueueActiveAnimations.delete(el);
        }
        finish();
      },
      { once: true }
    );
    animation.addEventListener(
      'cancel',
      () => {
        if (runtimeQueueActiveAnimations.get(el) === animation) {
          runtimeQueueActiveAnimations.delete(el);
        }
      },
      { once: true }
    );
    return;
  }

  requestAnimationFrame(() => {
    el.style.transition = `transform ${RUNTIME_QUEUE_ANIM_DURATION}ms ${RUNTIME_QUEUE_ANIM_EASING}`;
    el.style.transform = to;
    const timer = window.setTimeout(() => {
      runtimeQueueFallbackTimers.delete(el);
      finish();
    }, RUNTIME_QUEUE_ANIM_DURATION + 24);
    runtimeQueueFallbackTimers.set(el, timer);
  });
};

const resolveRuntimeQueueOffset = (el: HTMLElement): number => {
  const rectHeight = Math.round(el.getBoundingClientRect().height);
  if (rectHeight > 0) {
    return rectHeight;
  }
  if (el.offsetHeight > 0) {
    return el.offsetHeight;
  }
  return 34;
};

const snapshotRuntimeQueueIds = (): string[] => {
  return runtimeQueuedMessagesForRender.value
    .map((entry) => String(entry?.id || '').trim())
    .filter((id) => !!id);
};

const spawnRuntimeQueueLeaveGhost = (id: string) => {
  const sourceEl = runtimeQueuePrevElements.get(id);
  const sourceRect = runtimeQueuePrevRects.get(id);
  const listEl = runtimeQueueList.value;
  if (!sourceEl || !sourceRect || !listEl || !listEl.isConnected) {
    return;
  }

  const existingGhost = runtimeQueueGhosts.get(id);
  if (existingGhost) {
    stopRuntimeQueueAnimation(existingGhost);
    existingGhost.remove();
    runtimeQueueGhosts.delete(id);
  }

  const ghost = sourceEl.cloneNode(true) as HTMLElement;
  const listRect = listEl.getBoundingClientRect();
  const top = sourceRect.top - listRect.top;
  const width = sourceRect.width > 0 ? sourceRect.width : listRect.width;

  ghost.style.position = 'absolute';
  ghost.style.left = '0';
  ghost.style.top = `${Math.round(top)}px`;
  ghost.style.width = `${Math.round(width)}px`;
  ghost.style.pointerEvents = 'none';
  ghost.style.zIndex = '0';
  ghost.style.margin = '0';
  ghost.querySelectorAll('button').forEach((button) => {
    if (button instanceof HTMLButtonElement) {
      button.disabled = true;
    }
  });

  listEl.appendChild(ghost);
  runtimeQueueGhosts.set(id, ghost);

  const offset = resolveRuntimeQueueOffset(ghost);
  playRuntimeQueueTransform(ghost, 0, offset, () => {
    runtimeQueueGhosts.delete(id);
    ghost.remove();
  });
};

const animateRuntimeQueueEnter = (el: HTMLElement) => {
  const offset = resolveRuntimeQueueOffset(el);
  playRuntimeQueueTransform(el, offset, 0);
};

onBeforeUpdate(() => {
  runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length, ...runtimeQueueRenderedIds);
  runtimeQueuePrevRects.clear();
  runtimeQueuePrevElements.clear();

  runtimeQueueRenderedIds.forEach((id) => {
    const el = runtimeQueueItemRefs.get(id);
    if (!el?.isConnected) {
      return;
    }
    runtimeQueuePrevRects.set(id, el.getBoundingClientRect());
    runtimeQueuePrevElements.set(id, el);
  });
});

onUpdated(() => {
  const currentIds = snapshotRuntimeQueueIds();
  if (!runtimeQueueAnimationReady) {
    runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length, ...currentIds);
    runtimeQueueAnimationReady = true;
    runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
    runtimeQueuePrevRects.clear();
    runtimeQueuePrevElements.clear();
    return;
  }

  const previousIdSet = new Set(runtimeQueuePrevIds);
  const currentIdSet = new Set(currentIds);
  const enteringIdSet = new Set(currentIds.filter((id) => !previousIdSet.has(id)));
  const leavingIds = runtimeQueuePrevIds.filter((id) => !currentIdSet.has(id));

  leavingIds.forEach((id) => {
    spawnRuntimeQueueLeaveGhost(id);
    runtimeQueueItemRefs.delete(id);
  });

  currentIds.forEach((id) => {
    const el = runtimeQueueItemRefs.get(id);
    if (!el?.isConnected) {
      return;
    }
    const prevRect = runtimeQueuePrevRects.get(id);
    if (!prevRect || enteringIdSet.has(id)) {
      animateRuntimeQueueEnter(el);
      return;
    }
    const nextRect = el.getBoundingClientRect();
    const deltaY = prevRect.top - nextRect.top;
    if (Math.abs(deltaY) < 0.5) {
      return;
    }
    playRuntimeQueueTransform(el, deltaY, 0);
  });

  runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length, ...currentIds);
  runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
  runtimeQueuePrevRects.clear();
  runtimeQueuePrevElements.clear();
});

const hasComposerContent = computed(() => {
  const hasText = !!(props.inputMessage || '').trim();
  if (props.streamingMessage) {
    return hasText;
  }
  const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0;
  const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0;
  return hasText || hasImages || hasVideos;
});

const showStopIcon = computed(() => props.streamingMessage && !hasComposerContent.value);

const sendButtonDisabled = computed(() => {
  if (!props.isConnected) {
    return true;
  }
  if (props.streamingMessage) {
    return false;
  }
  if (props.inputLocked) {
    return true;
  }
  if (props.mediaUploading) {
    return true;
  }
  return !hasComposerContent.value;
});

const hasRuntimeLayoutExpansion = computed(() => {
  const hasQueue = runtimeQueuedMessagesForRender.value.length > 0;
  const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0;
  const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0;
  return hasQueue || floatingStatusVisible.value || skillSlashOpen.value || hasImages || hasVideos || !!props.inputIsMultiline || !!props.goalModeArmed || !!props.goalRunning || goalCompleted.value;
});

const goalCompleted = computed(() => String(props.goalProgress?.status || '').toLowerCase() === 'done');

const handleSlashMenuTransitionStart = () => {
  slashMenuTransitioning.value = true;
};

const handleSlashMenuAfterEnter = () => {
  slashMenuTransitioning.value = false;
  emitComposerHeight();
};

const handleSlashMenuAfterLeave = () => {
  slashMenuTransitioning.value = false;
  emitComposerHeight();
};

// 当消息队列非空或 skill 选择菜单展开时，把目标横幅收起为仅圆点，
// 让队列/菜单紧贴输入框，不再被横幅顶高。队列清空且无 skill 选择时自动恢复。
const goalBannerCollapsed = computed(
  () =>
    runtimeQueuedMessagesForRender.value.length > 0 ||
    runtimeQueueTransitioning.value ||
    skillSlashMenuOpen.value ||
    slashMenuTransitioning.value
);

const goalBannerTitle = computed(() => {
  if (props.goalRunning) return '目标模式运行中';
  if (goalCompleted.value) return '目标模式完成';
  return '目标模式已就绪';
});

const collectComposerVisualHeight = () => {
  const root = inputAreaRoot.value;
  const shell = compactInputShell.value;
  if (!root || !shell) {
    return 0;
  }
  // 用 offset 布局坐标（offsetTop/offsetHeight）计算可视高度，而非 getBoundingClientRect。
  // 后者会把进入/离开动画的 transform 一起算进去：状态栏进场时还停在 translateY(44px) 的低位，
  // 测得的增长量≈0，要等 260ms 动画结束才量到真实高度、再迟一拍下降——表现为「先挡住、完全出现后才下降」。
  // offset 系列只反映静止布局、不受 transform 影响，状态栏一挂载就按最终高度上报，
  // 输入栏下降与状态栏出现动画得以同时发生。这些浮层与 shell 同为 .stadium-input-wrapper 的直接子节点，
  // 其 offsetParent 一致，offsetTop 可直接比较。
  let top = shell.offsetTop;
  let bottom = shell.offsetTop + shell.offsetHeight;
  const slashMenu = root.querySelector('.skill-slash-menu');
  if (slashMenu instanceof HTMLElement) {
    const sTop = slashMenu.offsetTop;
    const sBottom = slashMenu.offsetTop + slashMenu.offsetHeight;
    return Math.max(0, Math.max(bottom, sBottom) - Math.min(top, sTop));
  }
  // 收起态横幅是脱离流、浮在角上的小圆点，不应计入为聊天区预留的高度，
  // 否则会把消息区可滚动范围顶高。故排除 .goal-mode-banner--collapsed。
  const nodes = root.querySelectorAll('.runtime-queue-list:not(.runtime-queue-list--empty), .floating-project-status');
  nodes.forEach((node) => {
    if (!(node instanceof HTMLElement)) return;
    // 离开态：floatingStatusVisible 已翻 false 但元素仍在 DOM 中做退场动画。
    // 此时不再计入它的高度，让输入栏立即回升、与状态栏的退场动画同步。
    if (node.classList.contains('floating-project-status') && !floatingStatusVisible.value) return;
    top = Math.min(top, node.offsetTop);
    bottom = Math.max(bottom, node.offsetTop + node.offsetHeight);
  });
  return Math.max(0, bottom - top);
};

const collectComposerShellHeight = () => {
  const shell = compactInputShell.value;
  if (!shell) {
    return 0;
  }
  return Math.max(0, shell.getBoundingClientRect().height);
};

const emitComposerHeight = () => {
  const visualHeight = collectComposerVisualHeight();
  if (!visualHeight) {
    return;
  }
  const shellHeight = collectComposerShellHeight();
  if (!hasRuntimeLayoutExpansion.value) {
    baselineComposerVisualHeight.value = visualHeight;
  } else if (baselineComposerVisualHeight.value <= 0 && shellHeight > 0) {
    baselineComposerVisualHeight.value = shellHeight;
  } else if (baselineComposerVisualHeight.value > 0 && shellHeight > 0) {
    baselineComposerVisualHeight.value = Math.min(baselineComposerVisualHeight.value, shellHeight);
  } else if (baselineComposerVisualHeight.value <= 0) {
    baselineComposerVisualHeight.value = visualHeight;
  }
  const baseline = baselineComposerVisualHeight.value || visualHeight;
  const growth = Math.max(0, visualHeight - baseline);
  const baseReservedHeight = 80;
  const reservedHeight = Math.ceil(baseReservedHeight + growth);
  emit('composer-height-change', {
    reservedHeight,
    visualHeight,
    growth
  });
};

const adjustTextareaSize = () => {
  const root = inputAreaRoot.value?.querySelector('.stadium-input-editor') as HTMLElement | null;
  if (!root) {
    return;
  }
  const target = root;
  const previousScrollTop = target.scrollTop;
  const selectionShouldStayVisible =
    document.activeElement === target || target.contains(document.activeElement);
  target.style.height = 'auto';
  const computedStyle = window.getComputedStyle(target);
  const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20;
  const maxHeight = lineHeight * 6;
  const targetHeight = Math.min(target.scrollHeight, maxHeight);
  const lines = Math.max(1, Math.round(targetHeight / lineHeight));
  const multiline = targetHeight > lineHeight * 1.4;
  applyLineMetrics(lines, multiline);
  target.style.height = `${targetHeight}px`;
  keepEditorSelectionVisible(target, {
    previousScrollTop,
    selectionShouldStayVisible,
    padding: Math.max(4, lineHeight * 0.3)
  });
  nextTick(() => {
    emitComposerHeight();
  });
};

const keepEditorSelectionVisible = (
  target: HTMLElement,
  options: { previousScrollTop: number; selectionShouldStayVisible: boolean; padding: number }
) => {
  const maxScrollTop = Math.max(0, target.scrollHeight - target.clientHeight);
  if (maxScrollTop <= 0) {
    target.scrollTop = 0;
    return;
  }

  if (!options.selectionShouldStayVisible) {
    target.scrollTop = Math.min(maxScrollTop, Math.max(0, options.previousScrollTop));
    return;
  }

  requestAnimationFrame(() => {
    const instance = editor.value;
    if (!instance) {
      target.scrollTop = Math.min(maxScrollTop, Math.max(0, options.previousScrollTop));
      return;
    }

    try {
      const caretRect = instance.view.coordsAtPos(instance.state.selection.head);
      const targetRect = target.getBoundingClientRect();
      const padding = options.padding;
      const currentMaxScrollTop = Math.max(0, target.scrollHeight - target.clientHeight);
      let nextScrollTop = target.scrollTop;

      if (caretRect.bottom > targetRect.bottom - padding) {
        nextScrollTop += caretRect.bottom - targetRect.bottom + padding;
      } else if (caretRect.top < targetRect.top + padding) {
        nextScrollTop -= targetRect.top + padding - caretRect.top;
      }

      target.scrollTop = Math.min(currentMaxScrollTop, Math.max(0, nextScrollTop));
    } catch {
      target.scrollTop = Math.min(maxScrollTop, Math.max(0, options.previousScrollTop));
    }
  });
};

const onInput = (event: Event) => {
  const target = event.target as HTMLTextAreaElement;
  if (!props.isConnected || props.inputLocked) {
    target.value = props.inputMessage || '';
    return;
  }
  emit('update:input-message', target.value);
  emit('input-change');
  adjustTextareaSize();
  nextTick(refreshSkillSlashState);
};

const onFileChange = (event: Event) => {
  const target = event.target as HTMLInputElement;
  emit('file-selected', target?.files || null);
  if (target) {
    target.value = '';
  }
};

const prepareMessageForSend = () => editorJsonToMessage(editor.value?.getJSON() as JSONContent);

const getComposerDraftMeta = () => ({
  editor_json: editor.value?.getJSON() || null,
  skill_refs: editorJsonToMessage(editor.value?.getJSON() as JSONContent).skillRefs
});

const restoreComposerDraftMeta = (meta?: { skill_refs?: SkillItem[]; editor_json?: JSONContent } | null) => {
  const instance = editor.value;
  if (!instance) return;
  if (meta?.editor_json && typeof meta.editor_json === 'object') {
    syncingEditorFromProps = true;
    instance.commands.setContent(meta.editor_json);
    syncingEditorFromProps = false;
    emit('update:input-message', getEditorPlainText());
    nextTick(adjustTextareaSize);
  }
};

const triggerQuickUpload = () => {
  if (!props.isConnected || props.uploading) {
    return;
  }
  emit('quick-upload');
};

const currentPermissionLabel = computed(() => {
  const matched = (props.permissionOptions || []).find(
    (item) => item.value === props.currentPermissionMode
  );
  return matched ? matched.label : props.currentPermissionMode;
});

const currentExecutionLabel = computed(() => {
  const options = props.executionModeOptions || [];
  const currentMode = String(props.currentExecutionMode || '');
  const currentMatched = options.find((item) => item.value === currentMode);
  return currentMatched ? currentMatched.label : currentMode;
});

const modelContextWindow = computed(() => {
  const list = Array.isArray(props.modelOptions) ? props.modelOptions : [];
  const current = list.find((item) => item.key === props.currentModelKey);
  return Number(current?.contextWindow || 0);
});

const autoDeepCompressEnabled = computed(() => {
  return !!personalizationStore?.form?.auto_deep_compress_enabled;
});

const deepCompressLimit = computed(() => {
  const custom = Number(personalizationStore?.form?.deep_compress_trigger_tokens || 0);
  if (custom > 0) return custom;
  return 150000;
});

const contextUsageLimit = computed(() => {
  if (autoDeepCompressEnabled.value) {
    return deepCompressLimit.value;
  }
  if (modelContextWindow.value > 0) {
    return modelContextWindow.value;
  }
  return 0;
});

const contextUsagePercent = computed(() => {
  const limit = Number(contextUsageLimit.value || 0);
  if (limit <= 0) return 0;
  const current = Math.max(0, Number(props.currentContextTokens || 0));
  return Math.max(0, Math.min(100, (current / limit) * 100));
});

const contextUsagePercentLabel = computed(() => `${Math.round(contextUsagePercent.value)}%`);

const contextUsageColor = computed(() => {
  const percent = contextUsagePercent.value;
  if (percent < 40) return 'var(--state-success)';
  if (percent <= 80) return 'var(--state-warning)';
  return 'var(--state-danger)';
});

const formatCompactTokens = (value: number) => {
  const num = Math.max(0, Number(value || 0));
  if (num >= 1000) {
    const raw = num / 1000;
    const text = raw >= 100 ? Math.round(raw).toString() : raw.toFixed(1).replace(/\.0$/, '');
    return `${text}k`;
  }
  return `${Math.round(num)}`;
};

const contextUsageCompactText = computed(() => {
  const current = formatCompactTokens(props.currentContextTokens || 0);
  const limit = Number(contextUsageLimit.value || 0);
  if (limit <= 0) return `${current}/--`;
  return `${current}/${formatCompactTokens(limit)}`;
});

defineExpose({
  inputAreaRoot,
  stadiumShellOuter,
  compactInputShell,
  stadiumInput,
  fileUploadInput,
  emitComposerHeight,
  prepareMessageForSend,
  getComposerDraftMeta,
  restoreComposerDraftMeta
});

watch(
  () => props.inputMessage,
  async () => {
    const instance = editor.value;
    if (instance && !syncingEditorFromProps && getEditorPlainText() !== (props.inputMessage || '')) {
      syncingEditorFromProps = true;
      instance.commands.setContent(textToTiptapContent(props.inputMessage || ''));
      syncingEditorFromProps = false;
    }
    await nextTick();
    adjustTextareaSize();
    refreshSkillSlashState();
  }
);

watch(
  () => props.isConnected,
  async (connected) => {
    editor.value?.setEditable(!!connected && !props.inputLocked);
    if (!connected) {
      editor.value?.commands.blur();
    }
    await nextTick();
    adjustTextareaSize();
  }
);

watch(
  () => props.inputLocked,
  (locked) => {
    editor.value?.setEditable(props.isConnected && !locked);
  }
);

watch(
  () => props.currentConversationId,
  async () => {
    baselineComposerVisualHeight.value = 0;
    await nextTick();
    emitComposerHeight();
  }
);

watch(
  () => props.runtimeQueuedMessages,
  async () => {
    await nextTick();
    emitComposerHeight();
  },
  { deep: true }
);

watch(
  () => props.projectGitSummary,
  async () => {
    if (!projectGitSummaryForRender.value) {
      closeProjectGitMenu();
    }
    await nextTick();
    emitComposerHeight();
  },
  { deep: true }
);

watch(floatingStatusVisible, async () => {
  await nextTick();
  emitComposerHeight();
  window.setTimeout(() => {
    emitComposerHeight();
  }, 280);
});

watch(
  () => [props.selectedImages?.length || 0, props.selectedVideos?.length || 0],
  async () => {
    await nextTick();
    emitComposerHeight();
  }
);

// skill 菜单开关会切换目标横幅收起/展开态，需重新上报高度，
// 否则展开回来时聊天区预留高度不会同步恢复。
watch(goalBannerCollapsed, async () => {
  await nextTick();
  emitComposerHeight();
});

onMounted(() => {
  document.addEventListener('click', closeProjectGitMenu);
  adjustTextareaSize();
  nextTick(() => {
    emitComposerHeight();
    if (typeof ResizeObserver !== 'undefined') {
      composerResizeObserver = new ResizeObserver(() => {
        emitComposerHeight();
      });
      if (inputAreaRoot.value) {
        composerResizeObserver.observe(inputAreaRoot.value);
      }
      if (stadiumShellOuter.value) {
        composerResizeObserver.observe(stadiumShellOuter.value);
      }
      if (compactInputShell.value) {
        composerResizeObserver.observe(compactInputShell.value);
      }
    }
  });
});

onBeforeUnmount(() => {
  document.removeEventListener('click', closeProjectGitMenu);
  editor.value?.destroy();
  runtimeQueueItemRefs.forEach((el) => {
    if (el) {
      stopRuntimeQueueAnimation(el);
    }
  });
  runtimeQueueGhosts.forEach((ghost) => {
    stopRuntimeQueueAnimation(ghost);
    ghost.remove();
  });
  runtimeQueueGhosts.clear();
  runtimeQueueItemRefs.clear();
  runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
  runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length);
  runtimeQueuePrevRects.clear();
  runtimeQueuePrevElements.clear();
  runtimeQueueAnimationReady = false;
  if (runtimeQueueTransitionTimer !== null) {
    window.clearTimeout(runtimeQueueTransitionTimer);
    runtimeQueueTransitionTimer = null;
  }
  runtimeQueueTransitioning.value = false;
  if (composerResizeObserver) {
    composerResizeObserver.disconnect();
    composerResizeObserver = null;
  }
});
</script>

<style scoped>
.floating-project-status {
  position: absolute;
  left: calc(5% - 7px);
  bottom: calc(100% + 6px);
  z-index: auto;
  width: 90%;
  max-width: 90%;
  height: 34px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 4px 10px 4px 4px;
  border: 1px solid var(--claude-border);
  border-radius: 14px;
  background: var(--theme-surface-soft);
  box-shadow: none;
  pointer-events: auto;
}

.floating-project-status__left,
.floating-project-status__right {
  min-width: 0;
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

.floating-project-status__left {
  flex: 1 1 auto;
}

.floating-project-status__right {
  flex: 0 0 auto;
  margin-left: auto;
}

.floating-project-status__menu-wrap {
  position: relative;
  display: inline-flex;
  align-items: center;
  min-width: 0;
  height: 26px;
}

.floating-project-status__button,
.floating-project-status__notice {
  height: 26px;
  display: inline-flex;
  align-items: center;
  border: none;
  border-radius: 10px;
  background: transparent;
  color: var(--claude-text);
  font: inherit;
  font-size: 12px;
  line-height: 1.2;
  white-space: nowrap;
}

.floating-project-status__button {
  min-width: 0;
  padding: 0 9px;
  cursor: pointer;
  transition: background 140ms ease, color 140ms ease;
}

.floating-project-status__button:hover,
.floating-project-status__button--active,
.floating-project-status__notice--button:hover,
.floating-project-status__submenu button:hover {
  background: var(--theme-tab-active);
  box-shadow: 0 1px 2px var(--shadow-color);
}

.floating-project-status__button--project {
  max-width: min(260px, 36vw);
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
}

.floating-project-status__button--branch {
  color: var(--claude-text-secondary);
}

.floating-project-status__notice {
  padding: 0 8px;
  color: var(--claude-text-secondary);
}

.floating-project-status__notice--button {
  cursor: pointer;
}

.floating-project-status__stats {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 26px;
  padding: 0 8px;
  border: none;
  border-radius: 10px;
  background: transparent;
  font-size: 12px;
  line-height: 1.2;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition: background 140ms ease, color 140ms ease;
}

.floating-project-status__stats:hover {
  background: var(--theme-tab-active);
  box-shadow: 0 1px 2px var(--shadow-color);
}

.floating-project-status__add {
  display: inline-flex;
  align-items: center;
  color: var(--git-diff-add-text);
}

.floating-project-status__del {
  display: inline-flex;
  align-items: center;
  color: var(--git-diff-del-text);
}

.floating-project-status__submenu {
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  z-index: 46;
  width: max-content;
  min-width: 160px;
  max-width: min(260px, 72vw);
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 6px;
  border: 1px solid var(--claude-border);
  border-radius: 14px;
  background: var(--theme-surface-soft);
  box-shadow: none;
  overflow: hidden;
}

.floating-project-status__submenu button {
  width: 100%;
  min-width: 148px;
  height: 36px;
  padding: 0 10px;
  border: none;
  border-radius: 10px;
  background: transparent;
  color: var(--claude-text);
  font-size: 14px;
  line-height: 1.2;
  text-align: left;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
}

:global(html[data-theme='dark'] .floating-project-status),
:global(body[data-theme='dark'] .floating-project-status),
:global(html[data-theme='dark'] .floating-project-status__submenu),
:global(body[data-theme='dark'] .floating-project-status__submenu) {
  background: var(--badge-bg) !important;
  border-color: var(--border-card-strong);
}

:global(html[data-theme='dark'] .floating-project-status__button:hover),
:global(body[data-theme='dark'] .floating-project-status__button:hover),
:global(html[data-theme='dark'] .floating-project-status__notice--button:hover),
:global(body[data-theme='dark'] .floating-project-status__notice--button:hover),
:global(html[data-theme='dark'] .floating-project-status__stats:hover),
:global(body[data-theme='dark'] .floating-project-status__stats:hover),
:global(html[data-theme='dark'] .floating-project-status__submenu button:hover),
:global(body[data-theme='dark'] .floating-project-status__submenu button:hover) {
  box-shadow: 0 1px 2px var(--shadow-color);
}

.floating-status-motion-enter-active,
.floating-status-motion-leave-active {
  transition:
    opacity 180ms ease,
    transform 260ms cubic-bezier(0.25, 0.8, 0.25, 1);
  transform-origin: bottom center;
  will-change: transform, opacity;
  /* 状态栏本体永远在最底层：输入栏 shell(z-index:2) > / 菜单(z-index:1) > 状态栏(auto)。
     不在这里设置 z-index，避免创建低层 stacking context 后把二级菜单也压在输入栏下。 */
  z-index: auto;
}

.floating-status-motion-enter-from,
.floating-status-motion-leave-to {
  opacity: 0;
  transform: translateY(44px);
}

.floating-status-motion-enter-to,
.floating-status-motion-leave-from {
  opacity: 1;
  transform: translateY(0);
}

/* 目标模式横幅：与 + 按钮左对齐，置于输入框上方 */
.goal-mode-banner {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin: 0 0 6px 4px;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 12px;
  line-height: 1.4;
  color: var(--claude-text-secondary);
  background: color-mix(in srgb, var(--theme-surface-soft) 92%, var(--claude-text-tertiary) 8%);
  border: 1px solid var(--theme-chip-border);
  user-select: none;
  transition: gap 0.2s ease, padding 0.2s ease;
}
/* 收起态：仅保留圆点与外圈，文字消失、右侧收拢，圆点位置（左侧 padding）不变。
   关键：切到 absolute 脱离文档流，避免横幅占用一行流高度把队列/skill 菜单顶起来；
   锚定在输入框上方 6px、左 4px，与展开时的视觉位置一致，圆点位置不变。 */
.goal-mode-banner--collapsed {
  position: absolute;
  left: 4px;
  bottom: calc(100% + 6px);
  z-index: 2;
  margin: 0;
  gap: 0;
  padding-right: 10px;
}
.goal-mode-banner__text {
  overflow: hidden;
  white-space: nowrap;
  max-width: 200px;
  opacity: 1;
  transition: max-width 0.2s ease, opacity 0.18s ease;
}
.goal-mode-banner--collapsed .goal-mode-banner__text {
  max-width: 0;
  opacity: 0;
}
.goal-mode-banner--running {
  color: var(--claude-accent);
  border-color: var(--claude-accent);
  background: color-mix(in srgb, var(--theme-surface-soft) 82%, var(--claude-accent) 18%);
}
.goal-mode-banner--done {
  color: var(--claude-success);
  border-color: var(--claude-success);
  background: color-mix(in srgb, var(--theme-surface-soft) 82%, var(--claude-success) 18%);
}
:global(:root[data-theme='dark']) .goal-mode-banner,
:global(body[data-theme='dark']) .goal-mode-banner {
  color: var(--claude-text-secondary);
  background: color-mix(in srgb, var(--theme-surface-muted) 82%, var(--claude-text-tertiary) 18%);
  border-color: var(--theme-control-border-strong);
}
:global(:root[data-theme='dark']) .goal-mode-banner--running,
:global(body[data-theme='dark']) .goal-mode-banner--running {
  color: var(--text-primary);
  background: color-mix(in srgb, var(--theme-surface-muted) 72%, var(--claude-accent) 28%);
  border-color: color-mix(in srgb, var(--claude-accent) 70%, white 30%);
}
:global(:root[data-theme='dark']) .goal-mode-banner--done,
:global(body[data-theme='dark']) .goal-mode-banner--done {
  color: color-mix(in srgb, var(--state-success) 30%, white);
  background: color-mix(in srgb, var(--theme-surface-muted) 72%, var(--claude-success) 28%);
  border-color: color-mix(in srgb, var(--claude-success) 70%, white 30%);
}
.goal-mode-banner--clickable {
  cursor: pointer;
}
.goal-mode-banner__dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--claude-text-tertiary);
}
.goal-mode-banner--running .goal-mode-banner__dot {
  background: var(--claude-accent);
  animation: goal-banner-pulse 1.4s ease-in-out infinite;
}
.goal-mode-banner--done .goal-mode-banner__dot {
  background: var(--claude-success);
}
@keyframes goal-banner-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.35; }
}
.goal-banner-fade-enter-active,
.goal-banner-fade-leave-active {
  transition: opacity 0.18s ease, transform 0.18s ease;
}
.goal-banner-fade-enter-from,
.goal-banner-fade-leave-to {
  opacity: 0;
  transform: translateY(4px);
}

.image-inline-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  padding: 4px 10px 2px;
  line-height: 1.4;
}

.image-thumbnail-wrapper {
  position: relative;
  width: 60px;
  height: 60px;
  border-radius: 6px;
  overflow: hidden;
  cursor: pointer;
  border: 1px solid var(--border-default);
}

.image-thumbnail {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.image-remove-btn-hover {
  position: absolute;
  top: 0;
  right: 0;
  background: none;
  color: var(--on-accent);
  border: none;
  font-size: 20px;
  font-weight: bold;
  line-height: 1;
  cursor: pointer;
  display: none;
  padding: 2px 4px;
  text-shadow: 0 0 3px var(--text-shadow-legible);
  transition: color 0.15s ease;
}

.image-thumbnail-wrapper:hover .image-remove-btn-hover {
  display: block;
}

.image-remove-btn-hover:hover {
  color: var(--state-danger);
}
</style>
