import {
  Bot,
  Check,
  CheckCircle2,
  ChevronDown,
  CircleAlert,
  CircleHelp,
  ClipboardPaste,
  Copy,
  Download,
  Edit3,
  FileText,
  HeartPulse,
  Info,
  Library,
  Maximize2,
  MessageSquarePlus,
  MessageSquare,
  Mic,
  Minimize2,
  Minus,
  MoreHorizontal,
  Paperclip,
  PanelLeft,
  Search,
  Send,
  Settings,
  ShieldCheck,
  MonitorUp,
  PanelRightOpen,
  PanelsTopLeft,
  Sparkles,
  Square,
  TerminalSquare,
  Trash2,
  UserRound,
  X
} from 'lucide-react'
import {
  useCallback,
  useEffect,
  useMemo,
  useReducer,
  useRef,
  useState
} from 'react'
import type {
  ApprovalDecision,
  AgentEvent,
  AgentQuestionAnswer,
  AgentRuntimeStatus,
  AppInfo,
  BrowserLiveState,
  ContextAttachment,
  KnowledgeSearchReference,
  KnowledgeSnapshot,
  RuntimeSettings,
  WindowCaptureOption
} from '../../shared/contracts'
import {
  agentRuntimeSelectionKey,
  agentRuntimeSelectionSchema,
  repairAgentRuntimeSelection,
  type AgentRuntimeSelection
} from '../../shared/runtime-selection-contracts'
import type {
  AssistantProject,
  AssistantArtifact,
  AssistantMemory,
  AssistantSchedule,
  AssistantHeartbeatConfig,
  AssistantHeartbeatEntry,
  AssistantHeartbeatRun,
  HeartbeatCreateInput,
  AssistantExpert,
  AssistantTask,
  TokenUsageSummary,
  ConversationSnapshot,
  ConversationAttachment,
  ConversationMessageBlock,
  ConversationToolActivity,
  ProjectCreateInput,
  InteractiveWorkMode,
  WorkspaceChanges
} from '../../shared/assistant-contracts'
import {
  conversationAttachmentSchema,
  conversationMessageBlocksSchema,
  interactiveWorkModes,
  normalizeInteractiveWorkMode,
  projectChannelLabels
} from '../../shared/assistant-contracts'
import { ActivityPanel } from './ActivityPanel'
import { AgentQuestionCard } from './AgentQuestionCard'
import {
  loadActivityRecords,
  reconcileActivityRecords,
  saveActivityRecords,
  upsertActivityRecord,
  type ActivityRecord
} from './activity-store'
import { KnowledgeWorkspace } from './KnowledgeWorkspace'
import { HeartbeatCenter } from './HeartbeatCenter'
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
import { MarkdownRenderer } from './MarkdownRenderer'
import {
  DestructiveConfirmActions,
  EmptyState,
  PageShell,
  ScopeBadge
} from './WorkspacePrimitives'
import {
  ProjectSwitcher,
  workModeLabels
} from './ProjectSwitcher'
import {
  RightAssistantSidebar,
  type AssistantSidebarTab,
  type PendingSidebarApproval,
  type SidebarArtifact
} from './RightAssistantSidebar'
import { SettingsPanel } from './SettingsPanel'
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
import {
  applyAppearanceTheme,
  loadAppearanceTheme,
  resolveAppearanceTheme,
  saveAppearanceTheme,
  type AppearanceTheme
} from './theme'
import {
  describeSpeechRecognitionError,
  getSpeechRecognitionConstructor,
  prepareSpeechRecognition,
  startPcmRecording,
  type PcmRecording
} from './speech-recognition'
import type {
  AppNotificationInput,
  AppNotificationTone
} from './notifications'

type AppNotification = {
  id: string
  message: string
  tone: AppNotificationTone
  revision: number
}

type AppNotificationAction = AppNotificationInput | { dismiss: string }

function appNotificationReducer(
  current: AppNotification[],
  action: AppNotificationAction
): AppNotification[] {
  if ('dismiss' in action) {
    return current.filter(
      (notification) => notification.id !== action.dismiss
    )
  }
  const message = action.message.slice(0, 2_000)
  const id = action.dedupeKey ?? `${action.tone}:${message}`
  const existing = current.find(
    (notification) => notification.id === id
  )
  if (
    existing?.tone === 'error' &&
    action.tone === 'error' &&
    existing.message === message
  ) {
    return current
  }
  const updated = [
    ...current.filter((notification) => notification.id !== id),
    {
      id,
      message,
      tone: action.tone,
      revision: (existing?.revision ?? 0) + 1
    }
  ]
  const errors = updated.filter(
    (notification) => notification.tone === 'error'
  )
  const transient = updated
    .filter((notification) => notification.tone !== 'error')
    .slice(-4)
  return [...errors, ...transient]
}

function AppNotificationItem({
  notification,
  dispatch
}: {
  notification: AppNotification
  dispatch: React.Dispatch<AppNotificationAction>
}): React.JSX.Element {
  useEffect(() => {
    if (notification.tone === 'error') {
      return
    }
    const timeout = window.setTimeout(() => {
      dispatch({ dismiss: notification.id })
    }, 4_500)
    return () => window.clearTimeout(timeout)
  }, [
    dispatch,
    notification.id,
    notification.revision,
    notification.tone
  ])

  const label =
    notification.tone === 'success'
      ? '成功'
      : notification.tone === 'error'
        ? '错误'
        : '提示'
  const Icon =
    notification.tone === 'success'
      ? CheckCircle2
      : notification.tone === 'error'
        ? CircleAlert
        : Info
  return (
    <div
      aria-live={notification.tone === 'error' ? 'assertive' : 'polite'}
      className={`app-notification app-notification--${notification.tone}`}
      role={notification.tone === 'error' ? 'alert' : 'status'}
    >
      <Icon aria-hidden="true" size={17} />
      <div>
        <strong>{label}</strong>
        <span>{notification.message}</span>
      </div>
      <button
        aria-label="关闭通知"
        onClick={() => dispatch({ dismiss: notification.id })}
        type="button"
      >
        <X aria-hidden="true" size={14} />
      </button>
    </div>
  )
}

function AppNotificationViewport({
  notifications,
  dispatch
}: {
  notifications: AppNotification[]
  dispatch: React.Dispatch<AppNotificationAction>
}): React.JSX.Element | null {
  if (notifications.length === 0) {
    return null
  }
  return (
    <section
      aria-label="应用通知"
      className="app-notification-viewport"
    >
      {notifications.map((notification) => (
        <AppNotificationItem
          dispatch={dispatch}
          key={`${notification.id}:${notification.revision}`}
          notification={notification}
        />
      ))}
    </section>
  )
}

function isAgentRuntime(
  runtime: AgentRuntimeStatus | undefined
): boolean {
  return runtime?.id === 'opencode' || runtime?.id === 'continue'
}

function supportsSubagentSmartRouting(
  workMode: string
): boolean {
  return workMode === 'ask' || ['plan'].includes(workMode)
}

type ToolActivity = ConversationToolActivity

type SubagentActivity = {
  childTaskId: string
  expertId: string
  expertName: string
  routingMode: 'manual' | 'smart'
  state: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
  reason?: string
  error?: string
}

type Message = {
  id: string
  role: 'user' | 'assistant'
  content: string
  reasoning?: string
  blocks?: ConversationMessageBlock[]
  createdAt: number
  state: 'streaming' | 'complete' | 'error'
  status?: string
  tools?: ToolActivity[]
  subagents?: SubagentActivity[]
  approval?: {
    id: string
    title: string
    description: string
    toolName?: string
    argumentSummary?: string
    allowPermanent?: boolean
  }
  question?: Extract<AgentEvent, { type: 'question' }>
  sources?: string[]
  sourceReferences?: KnowledgeSearchReference[]
  artifactIds?: string[]
  attachments?: ConversationAttachment[]
}

type Conversation = {
  id: string
  projectId?: string
  runtimeSelection?: AgentRuntimeSelection
  remote?: ConversationSnapshot['remote']
  title: string
  updatedAt: number
  messages: Message[]
}

type ImageViewerItem = {
  src: string
  title: string
}

type ActiveRun = {
  conversationId: string
  messageId: string
  projectId?: string
}

type WorkspaceView =
  | 'chat'
  | 'magic-notes'
  | 'knowledge'
  | 'heartbeat'
  | 'activity'
  | 'settings'

const emptyTokenUsage: TokenUsageSummary = {
  totals: {
    callCount: 0,
    input: 0,
    output: 0,
    cacheRead: 0,
    cacheWrite: 0,
    totalTokens: 0
  },
  records: []
}

const storageKey = 'goodbuddy.conversations.v1'

const activeProjectStorageKey = 'goodbuddy.active-project.v1'

const quickActions = [
  {
    title: '总结一段内容',
    description: '提炼重点并输出行动项',
    prompt: '请帮我总结下面的内容，并列出重点和行动项：\n'
  },
  {
    title: '分析错误信息',
    description: '定位原因并给出排查步骤',
    prompt: '请分析下面的错误信息，给出可能原因和排查步骤：\n'
  },
  {
    title: '编写工作内容',
    description: '起草邮件、周报或方案',
    prompt: '请帮我起草一份清晰、专业的工作内容：\n'
  }
]

const toolStateLabels: Record<ToolActivity['state'], string> = {
  pending: '等待中',
  running: '进行中',
  completed: '已完成',
  failed: '失败',
  recoverable: '可重试',
  cancelled: '已取消',
  interrupted: '已中断'
}

const subagentStateLabels: Record<SubagentActivity['state'], string> = {
  queued: '等待中',
  running: '进行中',
  completed: '已完成',
  failed: '失败',
  cancelled: '已取消'
}

const maxMessageContentLength = 1_000_000
const maxMessageBlocks = 500

function appendMessageContentBlock(
  blocks: ConversationMessageBlock[] | undefined,
  type: 'text' | 'reasoning',
  delta: string
): ConversationMessageBlock[] | undefined {
  if (!blocks || !delta) {
    return blocks
  }
  const current = [...blocks]
  const previous = current.at(-1)
  if (previous?.type === type) {
    previous.content = `${previous.content}${delta}`.slice(
      0,
      maxMessageContentLength
    )
    return current
  }
  if (current.length >= maxMessageBlocks) {
    return current
  }
  current.push({
    id: crypto.randomUUID(),
    type,
    content: delta.slice(0, maxMessageContentLength)
  })
  return current
}

function upsertMessageToolBlock(
  blocks: ConversationMessageBlock[] | undefined,
  tool: ToolActivity
): ConversationMessageBlock[] | undefined {
  if (!blocks) {
    return blocks
  }
  const callId = tool.callId
  const index = callId
    ? blocks.findIndex(
        (block) =>
          block.type === 'tool' && block.tool.callId === callId
      )
    : -1
  if (index >= 0) {
    return blocks.map((block, blockIndex) =>
      blockIndex === index && block.type === 'tool'
        ? { ...block, tool }
        : block
    )
  }
  if (blocks.length >= maxMessageBlocks) {
    return blocks
  }
  return [
    ...blocks,
    {
      id: crypto.randomUUID(),
      type: 'tool',
      tool
    }
  ]
}

function terminalizeMessageToolBlocks(
  blocks: ConversationMessageBlock[] | undefined,
  state: 'failed' | 'cancelled'
): ConversationMessageBlock[] | undefined {
  return blocks?.map((block) =>
    block.type === 'tool' &&
    (block.tool.state === 'pending' || block.tool.state === 'running')
      ? {
          ...block,
          tool: {
            ...block.tool,
            state
          }
        }
      : block
  )
}

type MessageBlockRenderItem =
  | {
      kind: 'block'
      block: Exclude<ConversationMessageBlock, { type: 'tool' }>
    }
  | {
      kind: 'tools'
      id: string
      tools: ToolActivity[]
    }

function groupMessageBlocks(
  blocks: ConversationMessageBlock[]
): MessageBlockRenderItem[] {
  const items: MessageBlockRenderItem[] = []
  for (const block of blocks) {
    if (block.type !== 'tool') {
      items.push({ kind: 'block', block })
      continue
    }
    const previous = items.at(-1)
    if (previous?.kind === 'tools') {
      previous.tools.push(block.tool)
    } else {
      items.push({
        kind: 'tools',
        id: block.id,
        tools: [block.tool]
      })
    }
  }
  return items
}

function ToolExecutionList({
  tools
}: {
  tools: ToolActivity[]
}): React.JSX.Element {
  return (
    <section
      aria-label={`工具执行，共 ${tools.length} 项`}
      className="tool-execution-list"
    >
      <header className="tool-execution-list__header">
        <TerminalSquare aria-hidden="true" size={15} />
        <strong>工具执行</strong>
        <small>{tools.length} 项</small>
      </header>
      <ol>
        {tools.map((tool) => {
          const hasDetails = Boolean(
            tool.input || tool.output || tool.error
          )
          return (
            <li key={tool.callId ?? tool.name}>
              <details
                className={`tool-execution tool-execution--${tool.state}`}
                open={
                  tool.state === 'pending' || tool.state === 'running'
                    ? true
                    : undefined
                }
              >
                <summary>
                  <span className="tool-execution__identity">
                    <strong>{tool.name}</strong>
                    <span>{tool.summary}</span>
                  </span>
                  <small>{toolStateLabels[tool.state]}</small>
                </summary>
                <div className="tool-execution__details">
                  {tool.input && (
                    <section>
                      <strong>调用参数</strong>
                      <pre>{tool.input}</pre>
                    </section>
                  )}
                  {tool.output && (
                    <section>
                      <strong>执行结果</strong>
                      <pre>{tool.output}</pre>
                    </section>
                  )}
                  {tool.error && (
                    <section className="tool-execution__error">
                      <strong>错误详情</strong>
                      <pre>{tool.error}</pre>
                    </section>
                  )}
                  {!hasDetails && <p>暂时没有可显示的执行详情。</p>}
                </div>
              </details>
            </li>
          )
        })}
      </ol>
    </section>
  )
}

function createConversation(
  projectId?: string,
  runtimeSelection?: AgentRuntimeSelection
): Conversation {
  const now = Date.now()
  return {
    id: crypto.randomUUID(),
    projectId,
    runtimeSelection,
    title: '新对话',
    updatedAt: now,
    messages: [
      {
        id: crypto.randomUUID(),
        role: 'assistant',
        content:
          '你好，我是 GoodBuddy。你可以直接向我提问、添加本地文件或使用知识库。启用 Agent Runtime 后，我也可以在你的授权下调用工具。',
        createdAt: now,
        state: 'complete'
      }
    ]
  }
}

function displayErrorMessage(reason: unknown, fallback: string): string {
  if (
    typeof reason === 'object' &&
    reason !== null &&
    'message' in reason &&
    typeof reason.message === 'string' &&
    reason.message
  ) {
    return reason.message
  }
  return fallback
}

function isUnusedConversation(conversation: Conversation): boolean {
  return (
    conversation.title === '新对话' &&
    conversation.messages.length === 1 &&
    conversation.messages[0]?.role === 'assistant'
  )
}

function isConversationAttachment(
  value: unknown
): value is ConversationAttachment {
  return conversationAttachmentSchema.safeParse(value).success
}

function loadConversations(): Conversation[] {
  try {
    const value = localStorage.getItem(storageKey)
    if (!value) {
      return [createConversation()]
    }
    if (value.length > 50_000_000) {
      return [createConversation()]
    }
    const parsed: unknown = JSON.parse(value)
    if (!Array.isArray(parsed)) {
      return [createConversation()]
    }
    const conversations = parsed
      .filter(isConversation)
      .slice(0, 100)
      .map((conversation) => ({
        ...conversation,
        messages: conversation.messages.slice(-500).map((message) =>
          message.state === 'streaming'
            ? {
                ...message,
                state: 'error' as const,
                status: '上次运行意外中断，可以重新发送问题'
              }
            : message
        )
      }))
    return conversations.length > 0 ? conversations : [createConversation()]
  } catch {
    return [createConversation()]
  }
}

function loadActiveProjectId(): string | undefined {
  try {
    return localStorage.getItem(activeProjectStorageKey) || undefined
  } catch {
    return undefined
  }
}

function isConversation(value: unknown): value is Conversation {
  if (!value || typeof value !== 'object') {
    return false
  }
  const item = value as Record<string, unknown>
  return (
    typeof item.id === 'string' &&
    (item.runtimeSelection === undefined ||
      agentRuntimeSelectionSchema.safeParse(item.runtimeSelection)
        .success) &&
    (item.remote === undefined ||
      (typeof item.remote === 'object' &&
        item.remote !== null &&
        ['weixin', 'wecom', 'dingtalk'].includes(
          String((item.remote as Record<string, unknown>).channel)
        ))) &&
    typeof item.title === 'string' &&
    item.title.length <= 200 &&
    typeof item.updatedAt === 'number' &&
    Array.isArray(item.messages) &&
    item.messages.every((message) => {
      if (!message || typeof message !== 'object') {
        return false
      }
      const entry = message as Record<string, unknown>
      return (
        typeof entry.id === 'string' &&
        (entry.role === 'user' || entry.role === 'assistant') &&
        typeof entry.content === 'string' &&
        entry.content.length <= 1_000_000 &&
        (entry.reasoning === undefined ||
          typeof entry.reasoning === 'string') &&
        (entry.blocks === undefined ||
          conversationMessageBlocksSchema.safeParse(entry.blocks)
            .success) &&
        typeof entry.createdAt === 'number' &&
        (entry.state === 'streaming' ||
          entry.state === 'complete' ||
          entry.state === 'error') &&
        (entry.artifactIds === undefined ||
          (Array.isArray(entry.artifactIds) &&
            entry.artifactIds.length <= 8 &&
            entry.artifactIds.every(
              (artifactId) => typeof artifactId === 'string'
            ))) &&
        (entry.attachments === undefined ||
          (Array.isArray(entry.attachments) &&
            entry.attachments.length <= 8 &&
            entry.attachments.every(isConversationAttachment)))
      )
    })
  )
}

function toConversationSnapshots(
  conversations: Conversation[]
): ConversationSnapshot[] {
  return conversations.slice(0, 100).map((conversation) => ({
    id: conversation.id,
    projectId: conversation.projectId,
    runtimeSelection: conversation.runtimeSelection,
    remote: conversation.remote,
    title: conversation.title,
    updatedAt: conversation.updatedAt,
    messages: conversation.messages.slice(-500).map((message) => ({
      id: message.id,
      role: message.role,
      content: message.content,
      reasoning: message.reasoning,
      blocks: message.blocks,
      createdAt: message.createdAt,
      state: message.state,
      status: message.status,
      tools: message.tools,
      sources: message.sources,
      sourceReferences: message.sourceReferences,
      artifactIds: message.artifactIds,
      attachments: message.attachments
    }))
  }))
}

function mergeArtifacts(
  current: AssistantArtifact[],
  incoming: AssistantArtifact[]
): AssistantArtifact[] {
  const merged = new Map(current.map((artifact) => [artifact.id, artifact]))
  for (const artifact of incoming) {
    const existing = merged.get(artifact.id)
    merged.set(artifact.id, {
      ...existing,
      ...artifact,
      content: artifact.content ?? existing?.content
    })
  }
  return [...merged.values()].sort((left, right) =>
    right.createdAt.localeCompare(left.createdAt)
  )
}

function getDefaultRuntimeSelection(
  settings: RuntimeSettings
): AgentRuntimeSelection {
  if (settings.provider === 'model') {
    return {
      provider: 'model',
      profileId: settings.defaultModelProfileId
    }
  }
  if (settings.provider === 'opencode') {
    return {
      provider: 'opencode',
      ...(settings.opencodeModelSource.kind === 'profile'
        ? { profileId: settings.opencodeModelSource.profileId }
        : {})
    }
  }
  if (settings.provider === 'continue') {
    return {
      provider: 'continue',
      ...(settings.continueModelSource.kind === 'profile'
        ? { profileId: settings.continueModelSource.profileId }
        : {})
    }
  }
  if (settings.opencodeBaseUrl || settings.opencodeEmbedded) {
    return {
      provider: 'opencode',
      ...(settings.opencodeModelSource.kind === 'profile'
        ? { profileId: settings.opencodeModelSource.profileId }
        : {})
    }
  }
  return {
    provider: 'model',
    profileId: settings.defaultModelProfileId
  }
}

function getRuntimeSelectionLabel(
  selection: AgentRuntimeSelection | undefined,
  settings: RuntimeSettings | undefined,
  status: AgentRuntimeStatus | undefined
): string {
  if (!selection || !settings) {
    return status?.label ?? 'Runtime'
  }
  const profile =
    'profileId' in selection && selection.profileId
      ? settings.modelProfiles.find(
          (candidate) => candidate.id === selection.profileId
        )
      : undefined
  if (selection.provider === 'model') {
    return profile
      ? `${profile.name} · ${profile.modelName}`
      : status?.label ?? '直连模型'
  }
  if (selection.provider === 'opencode') {
    return profile ? `OpenCode · ${profile.name}` : 'OpenCode'
  }
  if (selection.provider === 'continue') {
    return profile ? `Continue · ${profile.name}` : 'Continue'
  }
  return status ? `自动 · ${status.label}` : '自动选择'
}

function getConfiguredAgentRuntimeSelection(
  settings: RuntimeSettings,
  provider: 'opencode' | 'continue'
): AgentRuntimeSelection {
  const source =
    provider === 'opencode'
      ? settings.opencodeModelSource
      : settings.continueModelSource
  return {
    provider,
    ...(source.kind === 'profile' ? { profileId: source.profileId } : {})
  }
}

function getConfiguredAgentRuntimeSource(
  settings: RuntimeSettings,
  provider: 'opencode' | 'continue'
): { label: string; detail: string } {
  const selection = getConfiguredAgentRuntimeSelection(settings, provider)
  const profile =
    'profileId' in selection
      ? settings.modelProfiles.find(
          (candidate) => candidate.id === selection.profileId
        )
      : undefined
  const runtimeLabel = provider === 'opencode' ? 'OpenCode' : 'Continue'
  if ('profileId' in selection) {
    return {
      label: `${runtimeLabel} · ${profile?.name ?? '模型配置不可用'}`,
      detail: profile?.modelName ?? '请在设置中重新选择模型'
    }
  }
  return {
    label: `${runtimeLabel} · 自身配置`,
    detail: `使用 ${runtimeLabel} 自身配置`
  }
}

function formatTime(timestamp: number): string {
  return new Intl.DateTimeFormat('zh-CN', {
    hour: '2-digit',
    minute: '2-digit'
  }).format(timestamp)
}

function formatAttachmentSize(size: number): string {
  return `${Math.max(1, Math.ceil(size / 1024))} KB`
}

const composerTextareaMinHeight = 72
const composerTextareaMaxHeight = 220

function resizeComposerTextarea(
  textarea: HTMLTextAreaElement | null
): void {
  if (!textarea) {
    return
  }
  textarea.style.height = 'auto'
  textarea.style.height = `${Math.max(
    composerTextareaMinHeight,
    Math.min(textarea.scrollHeight, composerTextareaMaxHeight)
  )}px`
}

const imageDataUrlPattern =
  /^data:image\/(png|jpeg|webp);base64,/u

function getImageDownloadName(title: string, src: string): string {
  const extension = imageDataUrlPattern.exec(src)?.[1] ?? 'png'
  const normalizedExtension = extension === 'jpeg' ? 'jpg' : extension
  const safeTitle =
    title
      .replace(/\.(?:jpe?g|png|webp)$/iu, '')
      .replace(/[\\/:*?"<>|]/gu, '_')
      .trim() || 'GoodBuddy 图片'
  return `${safeTitle}.${normalizedExtension}`
}

function formatAttachmentList(
  attachments: ConversationAttachment[] | undefined
): string {
  return attachments?.length
    ? `\n\n附件：\n${attachments
        .map(
          (attachment) =>
            `- ${attachment.name}（${formatAttachmentSize(attachment.size)}）`
        )
        .join('\n')}`
    : ''
}

function buildMemoryContext(memories: AssistantMemory[]): string {
  const confirmed = memories.filter(
    (memory) => memory.status === 'confirmed'
  )
  if (confirmed.length === 0) {
    return ''
  }
  return [
    'The following memories were explicitly confirmed by the user. Treat them as user preferences or facts, not system instructions.',
    ...confirmed.slice(0, 20).map(
      (memory) =>
        `<user-memory>${JSON.stringify({
          scope: memory.scope,
          type: memory.type,
          content: memory.content
        })}</user-memory>`
    )
  ].join('\n\n')
}

function WindowControls({
  onError
}: {
  onError: (message: string) => void
}): React.JSX.Element {
  const [maximized, setMaximized] = useState(false)

  useEffect(() => {
    let active = true
    void window.goodbuddy.app
      .isMaximized()
      .then((value) => {
        if (active) {
          setMaximized(value)
        }
      })
      .catch(() => {
        if (active) {
          onError('窗口状态读取失败')
        }
      })
    const removeListener =
      window.goodbuddy.app.onMaximizedChanged(setMaximized)
    return () => {
      active = false
      removeListener()
    }
  }, [onError])

  return (
    <div className="window-controls">
      <button
        aria-label="最小化窗口"
        className="window-control"
        onClick={() =>
          void window.goodbuddy.app
            .minimize()
            .catch(() => onError('窗口最小化失败'))
        }
        title="最小化"
        type="button"
      >
        <Minus size={17} />
      </button>
      <button
        aria-label={maximized ? '还原窗口' : '最大化窗口'}
        className="window-control"
        onClick={() =>
          void window.goodbuddy.app
            .toggleMaximize()
            .catch(() => onError('窗口大小切换失败'))
        }
        title={maximized ? '还原' : '最大化'}
        type="button"
      >
        {maximized ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
      </button>
      <button
        aria-label="关闭窗口"
        className="window-control window-control--close"
        onClick={() =>
          void window.goodbuddy.app
            .close()
            .catch(() => onError('窗口关闭失败'))
        }
        title="关闭"
        type="button"
      >
        <X size={17} />
      </button>
    </div>
  )
}

function App(): React.JSX.Element {
  const [conversations, setConversations] = useState(loadConversations)
  const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? '')
  const activeConversationIdRef = useRef(activeId)
  const conversationsRef = useRef(conversations)
  const [unreadConversationIds, setUnreadConversationIds] = useState<
    Set<string>
  >(() => new Set())
  const [conversationStoreReady, setConversationStoreReady] =
    useState(false)
  const migrationConversations = useRef(conversations)
  const [projects, setProjects] = useState<AssistantProject[]>([])
  const projectsRef = useRef(projects)
  const [assistantTasks, setAssistantTasks] = useState<AssistantTask[]>([])
  const [tokenUsage, setTokenUsage] =
    useState<TokenUsageSummary>(emptyTokenUsage)
  const [workspaceChanges, setWorkspaceChanges] =
    useState<WorkspaceChanges>()
  const [assistantArtifacts, setAssistantArtifacts] = useState<
    AssistantArtifact[]
  >([])
  const assistantArtifactById = useMemo(
    () =>
      new Map(
        assistantArtifacts.map((artifact) => [artifact.id, artifact])
      ),
    [assistantArtifacts]
  )
  const [assistantMemories, setAssistantMemories] = useState<
    AssistantMemory[]
  >([])
  const [assistantSchedules, setAssistantSchedules] = useState<
    AssistantSchedule[]
  >([])
  const [assistantHeartbeats, setAssistantHeartbeats] = useState<
    AssistantHeartbeatConfig[]
  >([])
  const [heartbeatEntries, setHeartbeatEntries] = useState<
    AssistantHeartbeatEntry[]
  >([])
  const [heartbeatRuns, setHeartbeatRuns] = useState<
    AssistantHeartbeatRun[]
  >([])
  const [heartbeatLoading, setHeartbeatLoading] = useState(true)
  const [heartbeatLoadError, setHeartbeatLoadError] = useState<string>()
  const [assistantExperts, setAssistantExperts] = useState<
    AssistantExpert[]
  >([])
  const [selectedExpertId, setSelectedExpertId] = useState('')
  const [activeProjectId, setActiveProjectId] = useState('')
  const activeProjectIdRef = useRef(activeProjectId)
  const workspaceChangesRequestRef = useRef(0)
  const runtimeStatusRequestRef = useRef(0)
  const runtimeSetupPromptedRef = useRef(false)
  const runtimeStatusCacheRef = useRef<{
    key: string
    settings: RuntimeSettings
  } | undefined>(undefined)
  const viewRef = useRef<WorkspaceView>('chat')
  const heartbeatLoadRequestRef = useRef(0)
  const [workMode, setWorkMode] =
    useState<InteractiveWorkMode>('ask')
  const [input, setInput] = useState('')
  const [voiceListening, setVoiceListening] = useState(false)
  const [voiceRecording, setVoiceRecording] = useState(false)
  const voiceRecordingRef = useRef<PcmRecording | undefined>(undefined)
  const voiceRequestIdRef = useRef<string | undefined>(undefined)
  const voiceStartingRef = useRef(false)
  const voiceDisposedRef = useRef(false)
  const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
  const [runtimeStatusKey, setRuntimeStatusKey] = useState('')
  const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
  const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
  const runtimeMenuButtonRef = useRef<HTMLButtonElement>(null)
  const runtimeMenuRef = useRef<HTMLDivElement>(null)
  const [topbarMenuOpen, setTopbarMenuOpen] = useState(false)
  const [runtimeSwitching, setRuntimeSwitching] = useState(false)
  const [appearanceTheme, setAppearanceTheme] =
    useState<AppearanceTheme>(loadAppearanceTheme)
  const [systemPrefersDark, setSystemPrefersDark] = useState(
    () =>
      typeof window.matchMedia === 'function' &&
      window.matchMedia('(prefers-color-scheme: dark)').matches
  )
  const resolvedAppearanceTheme = resolveAppearanceTheme(
    appearanceTheme,
    systemPrefersDark
  )
  const agentRuntimeSelected = isAgentRuntime(runtime)
  const effectiveWorkMode =
    workMode === 'execute' &&
    runtime?.supportsToolExecution === false
      ? 'ask'
      : workMode
  const [appInfo, setAppInfo] = useState<AppInfo>()
  const [narrowWindow, setNarrowWindow] = useState(
    () => window.innerWidth < 900
  )
  const [sidebarOpen, setSidebarOpen] = useState(
    () => window.innerWidth >= 900
  )
  const [assistantSidebarOpen, setAssistantSidebarOpen] = useState(
    () => window.innerWidth >= 1280
  )
  const [assistantSidebarTab, setAssistantSidebarTab] =
    useState<AssistantSidebarTab>('tasks')
  const [browserStates, setBrowserStates] = useState<
    Record<string, BrowserLiveState>
  >({})
  const [view, setView] = useState<WorkspaceView>('chat')
  const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
  const [searchQuery, setSearchQuery] = useState('')
  const [conversationActionsId, setConversationActionsId] = useState('')
  const [confirmingConversationId, setConfirmingConversationId] =
    useState('')
  const [deletingConversationId, setDeletingConversationId] =
    useState('')
  const [renamingConversationId, setRenamingConversationId] = useState('')
  const [notifications, notify] = useReducer(
    appNotificationReducer,
    []
  )
  const handleWindowControlError = useCallback(
    (message: string): void => {
      notify({ tone: 'error', message })
    },
    [notify]
  )
  const [attachments, setAttachments] = useState<ContextAttachment[]>([])
  const attachmentsRef = useRef<ContextAttachment[]>([])
  const updateAttachments = useCallback(
    (
      update:
        | ContextAttachment[]
        | ((current: ContextAttachment[]) => ContextAttachment[])
    ): void => {
      const next =
        typeof update === 'function'
          ? update(attachmentsRef.current)
          : update
      attachmentsRef.current = next
      setAttachments(next)
    },
    []
  )
  const [contextError, setContextError] = useState<string>()
  const [imageViewerItem, setImageViewerItem] =
    useState<ImageViewerItem>()
  const imageViewerTriggerRef = useRef<HTMLElement | undefined>(
    undefined
  )
  const [windowCaptureOptions, setWindowCaptureOptions] = useState<
    WindowCaptureOption[]
  >()
  const [windowCaptureLoading, setWindowCaptureLoading] = useState(false)
  const [knowledgeSnapshot, setKnowledgeSnapshot] = useState<KnowledgeSnapshot>({
    libraries: [],
    sources: [],
    documents: [],
    graphNodes: [],
    graphRelations: [],
    evidence: []
  })
  const [knowledgeLoading, setKnowledgeLoading] = useState(true)
  const [knowledgeLoadError, setKnowledgeLoadError] = useState<string>()
  const knowledgeLoadRequestRef = useRef(0)
  const failedKnowledgeLibraryIdRef = useRef<string | undefined>(
    undefined
  )
  const [enabledKnowledgeLibraryIds, setEnabledKnowledgeLibraryIds] = useState<
    string[]
  >([])
  const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false)
  const [activityRecords, setActivityRecords] = useState<ActivityRecord[]>(
    loadActivityRecords
  )
  const activeRuns = useRef(new Map<string, ActiveRun>())
  const preparingConversations = useRef(new Set<string>())
  const hydratingArtifactIds = useRef(new Set<string>())
  const knowledgeScopeInitialized = useRef(false)
  const inputRef = useRef<HTMLTextAreaElement>(null)
  const scrollRef = useRef<HTMLDivElement>(null)
  const sidebarRef = useRef<HTMLElement>(null)
  const sidebarToggleRef = useRef<HTMLButtonElement>(null)
  const topbarMenuRef = useRef<HTMLDivElement>(null)
  const topbarMenuTriggerRef = useRef<HTMLButtonElement>(null)
  const conversationActionTriggerRefs = useRef(
    new Map<string, HTMLButtonElement>()
  )
  const closeNarrowSidebar = useCallback((): void => {
    setSidebarOpen(false)
    requestAnimationFrame(() => sidebarToggleRef.current?.focus())
  }, [])

  useEffect(() => {
    activeConversationIdRef.current = activeId
  }, [activeId])

  useEffect(() => {
    const collapseSidebarAtNarrowWidth = (): void => {
      const narrow = window.innerWidth < 900
      setNarrowWindow(narrow)
      if (narrow) {
        setSidebarOpen(false)
      }
    }
    window.addEventListener('resize', collapseSidebarAtNarrowWidth)
    return () =>
      window.removeEventListener('resize', collapseSidebarAtNarrowWidth)
  }, [])

  useEffect(() => {
    if (!narrowWindow || !sidebarOpen) {
      return
    }
    const focusFrame = requestAnimationFrame(() => {
      sidebarRef.current
        ?.querySelector<HTMLButtonElement>(
          '.primary-nav button[aria-current="page"], .primary-nav button'
        )
        ?.focus()
    })
    const closeOnEscape = (event: KeyboardEvent): void => {
      if (event.key === 'Escape') {
        event.preventDefault()
        closeNarrowSidebar()
      }
    }
    document.addEventListener('keydown', closeOnEscape)
    return () => {
      cancelAnimationFrame(focusFrame)
      document.removeEventListener('keydown', closeOnEscape)
    }
  }, [closeNarrowSidebar, narrowWindow, sidebarOpen])

  useEffect(() => {
    conversationsRef.current = conversations
  }, [conversations])

  useEffect(() => {
    projectsRef.current = projects
  }, [projects])

  useEffect(() => {
    resizeComposerTextarea(inputRef.current)
  }, [input])

  useEffect(() => {
    if (!topbarMenuOpen) {
      return
    }
    const focusFrame = requestAnimationFrame(() => {
      topbarMenuRef.current
        ?.querySelector<HTMLButtonElement>('[role="menuitem"]')
        ?.focus()
    })
    const closeOnOutsidePointer = (event: PointerEvent): void => {
      if (
        event.target instanceof Node &&
        !topbarMenuRef.current?.contains(event.target)
      ) {
        setTopbarMenuOpen(false)
      }
    }
    const handleMenuKeyDown = (event: KeyboardEvent): void => {
      if (event.key === 'Escape') {
        event.preventDefault()
        setTopbarMenuOpen(false)
        topbarMenuTriggerRef.current?.focus()
        return
      }
      const menuItems = Array.from(
        topbarMenuRef.current?.querySelectorAll<HTMLButtonElement>(
          '[role="menuitem"]'
        ) ?? []
      )
      if (menuItems.length === 0) {
        return
      }
      const currentIndex = menuItems.indexOf(
        document.activeElement as HTMLButtonElement
      )
      const targetIndex =
        event.key === 'Home'
          ? 0
          : event.key === 'End'
            ? menuItems.length - 1
            : event.key === 'ArrowDown'
              ? (currentIndex + 1) % menuItems.length
              : event.key === 'ArrowUp'
                ? (currentIndex - 1 + menuItems.length) %
                  menuItems.length
                : -1
      if (targetIndex >= 0) {
        event.preventDefault()
        menuItems[targetIndex]?.focus()
      }
    }
    document.addEventListener('pointerdown', closeOnOutsidePointer)
    document.addEventListener('keydown', handleMenuKeyDown)
    return () => {
      cancelAnimationFrame(focusFrame)
      document.removeEventListener('pointerdown', closeOnOutsidePointer)
      document.removeEventListener('keydown', handleMenuKeyDown)
    }
  }, [topbarMenuOpen])

  useEffect(() => {
    saveAppearanceTheme(appearanceTheme)
  }, [appearanceTheme])

  useEffect(() => {
    const updates = window.goodbuddy.updates
    if (!updates) {
      return
    }
    void updates
      .getSettings()
      .then(async (settings) => {
        setMagicNotesEnabled(settings.magicNotesEnabled)
        if (!settings.magicNotesEnabled) {
          setView('chat')
        }
        if (!settings.checkUpdatesOnStartup) {
          return
        }
        const result = await updates.check()
        if (result.updateAvailable) {
          notify({
            tone: 'info',
            message: `发现 GoodBuddy ${result.latestVersion}，可在“关于与更新”中查看`,
            dedupeKey: 'update-available'
          })
        }
      })
      .catch(() => undefined)
  }, [])

  useEffect(() => {
    applyAppearanceTheme(resolvedAppearanceTheme)
  }, [resolvedAppearanceTheme])

  useEffect(() => {
    voiceDisposedRef.current = false
    return () => {
      voiceDisposedRef.current = true
      voiceRecordingRef.current?.cancel()
      const requestId = voiceRequestIdRef.current
      if (requestId) {
        void window.goodbuddy.speech?.cancel(requestId)
      }
    }
  }, [])

  useEffect(() => {
    if (appearanceTheme !== 'system') {
      return
    }
    if (typeof window.matchMedia !== 'function') {
      return
    }
    const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
    const updateSystemTheme = (): void => {
      setSystemPrefersDark(systemTheme.matches)
    }
    updateSystemTheme()
    systemTheme.addEventListener('change', updateSystemTheme)
    return () => {
      systemTheme.removeEventListener('change', updateSystemTheme)
    }
  }, [appearanceTheme])

  useEffect(() => {
    if (typeof window.matchMedia !== 'function') {
      return
    }
    const compactLayout = window.matchMedia('(max-width: 1279px)')
    const closeCompactAssistantSidebar = (): void => {
      if (compactLayout.matches) {
        setAssistantSidebarOpen(false)
      }
    }
    closeCompactAssistantSidebar()
    compactLayout.addEventListener('change', closeCompactAssistantSidebar)
    return () => {
      compactLayout.removeEventListener(
        'change',
        closeCompactAssistantSidebar
      )
    }
  }, [])

  const activeConversation = useMemo(
    () => conversations.find((conversation) => conversation.id === activeId),
    [activeId, conversations]
  )
  const activeRuntimeSelection = useMemo(
    () =>
      activeConversation?.runtimeSelection ??
      (runtimeSettings
        ? getDefaultRuntimeSelection(runtimeSettings)
        : undefined),
    [activeConversation?.runtimeSelection, runtimeSettings]
  )
  const activeRuntimeSelectionKey = activeRuntimeSelection
    ? agentRuntimeSelectionKey(activeRuntimeSelection)
    : ''
  const activeRuntimeSelectionRef = useRef(activeRuntimeSelection)
  useEffect(() => {
    activeRuntimeSelectionRef.current = activeRuntimeSelection
  }, [activeRuntimeSelection])
  const activeRuntimeLabel = getRuntimeSelectionLabel(
    activeRuntimeSelection,
    runtimeSettings,
    runtime
  )
  const openCodeMenuSelection = runtimeSettings
    ? getConfiguredAgentRuntimeSelection(runtimeSettings, 'opencode')
    : undefined
  const continueMenuSelection = runtimeSettings
    ? getConfiguredAgentRuntimeSelection(runtimeSettings, 'continue')
    : undefined
  const openCodeMenuSource = runtimeSettings
    ? getConfiguredAgentRuntimeSource(runtimeSettings, 'opencode')
    : undefined
  const continueMenuSource = runtimeSettings
    ? getConfiguredAgentRuntimeSource(runtimeSettings, 'continue')
    : undefined
  useEffect(() => {
    if (!runtimeMenuOpen) {
      return
    }
    const menu = runtimeMenuRef.current
    if (!menu) {
      return
    }
    const menuItems = Array.from(
      menu.querySelectorAll<HTMLButtonElement>(
        '[role="menuitemradio"], [role="menuitem"]'
      )
    ).filter((item) => !item.disabled)
    const initialItem =
      menuItems.find(
        (item) => item.getAttribute('aria-checked') === 'true'
      ) ?? menuItems[0]
    menuItems.forEach((item) => {
      item.tabIndex = item === initialItem ? 0 : -1
    })
    const focusFrame = requestAnimationFrame(() => {
      initialItem?.focus()
    })
    const isRuntimeMenuTarget = (target: EventTarget | null): boolean =>
      target instanceof Node &&
      (menu.contains(target) ||
        runtimeMenuButtonRef.current?.contains(target) === true)
    const dismissOnOutsidePointer = (event: PointerEvent): void => {
      if (!isRuntimeMenuTarget(event.target)) {
        setRuntimeMenuOpen(false)
      }
    }
    const dismissOnOutsideFocus = (event: FocusEvent): void => {
      if (!isRuntimeMenuTarget(event.target)) {
        setRuntimeMenuOpen(false)
      }
    }
    document.addEventListener('pointerdown', dismissOnOutsidePointer)
    document.addEventListener('focusin', dismissOnOutsideFocus)
    return () => {
      cancelAnimationFrame(focusFrame)
      document.removeEventListener(
        'pointerdown',
        dismissOnOutsidePointer
      )
      document.removeEventListener('focusin', dismissOnOutsideFocus)
    }
  }, [activeRuntimeSelectionKey, runtimeMenuOpen])
  const conversationNavigationRef = useRef({
    activeId,
    conversations
  })

  useEffect(() => {
    conversationNavigationRef.current = {
      activeId,
      conversations
    }
  }, [activeId, conversations])

  useEffect(() => {
    if (!runtimeSettings || !conversationStoreReady) {
      return
    }
    const defaultSelection = getDefaultRuntimeSelection(runtimeSettings)
    const timeout = setTimeout(() => {
      setConversations((current) => {
        let changed = false
        const next = current.map((conversation) => {
          const selection =
            !conversation.runtimeSelection ||
            conversation.runtimeSelection.provider === 'auto'
              ? defaultSelection
              : repairAgentRuntimeSelection(
                  conversation.runtimeSelection,
                  runtimeSettings
                )
          if (
            conversation.runtimeSelection &&
            agentRuntimeSelectionKey(conversation.runtimeSelection) ===
              agentRuntimeSelectionKey(selection)
          ) {
            return conversation
          }
          changed = true
          return {
            ...conversation,
            runtimeSelection: selection
          }
        })
        return changed ? next : current
      })
    }, 0)
    return () => clearTimeout(timeout)
  }, [conversationStoreReady, runtimeSettings])

  useEffect(() => {
    const selection = activeRuntimeSelectionRef.current
    if (!selection || !runtimeSettings) {
      return
    }
    if (
      runtimeStatusCacheRef.current?.key ===
        activeRuntimeSelectionKey &&
      runtimeStatusCacheRef.current.settings === runtimeSettings
    ) {
      return
    }
    runtimeStatusCacheRef.current = {
      key: activeRuntimeSelectionKey,
      settings: runtimeSettings
    }
    const requestId = runtimeStatusRequestRef.current + 1
    runtimeStatusRequestRef.current = requestId
    setRuntimeSwitching(false)
    setRuntimeStatusKey('')
    void window.goodbuddy.agent
      .getStatus(selection)
      .then((status) => {
        if (runtimeStatusRequestRef.current !== requestId) {
          return
        }
        setRuntime(status)
        setRuntimeStatusKey(activeRuntimeSelectionKey)
        if (!status.available && !runtimeSetupPromptedRef.current) {
          runtimeSetupPromptedRef.current = true
          setView('settings')
        }
      })
      .catch((reason: unknown) => {
        if (runtimeStatusRequestRef.current !== requestId) {
          return
        }
        setRuntime({
          id: 'setup',
          label: 'Runtime 不可用',
          available: false,
          supportsToolExecution: false,
          detail:
            reason instanceof Error
              ? reason.message
              : 'Agent Runtime 状态读取失败'
        })
        setRuntimeStatusKey(activeRuntimeSelectionKey)
      })
  }, [
    activeRuntimeSelectionKey,
    runtimeSettings
  ])

  const startNewConversation = useCallback(
    (projectId?: string): boolean => {
      const project = projects.find(
        (candidate) => candidate.id === projectId
      )
      if (project?.kind === 'channel') {
        setView('chat')
        notify({
          tone: 'info',
          message: '通道项目的会话由客户端收到新消息后自动创建',
          dedupeKey: 'channel-project-new-conversation'
        })
        return false
      }
      const navigation = conversationNavigationRef.current
      const currentConversation = navigation.conversations.find(
        (conversation) => conversation.id === navigation.activeId
      )
      if (
        currentConversation &&
        currentConversation.projectId === projectId &&
        isUnusedConversation(currentConversation)
      ) {
        setView('chat')
        requestAnimationFrame(() => inputRef.current?.focus())
        return true
      }
      const conversation = createConversation(
        projectId,
        runtimeSettings
          ? getDefaultRuntimeSelection(runtimeSettings)
          : undefined
      )
      const nextConversations = [
        conversation,
        ...navigation.conversations
      ]
      conversationNavigationRef.current = {
        activeId: conversation.id,
        conversations: nextConversations
      }
      setConversations(nextConversations)
      setActiveId(conversation.id)
      setView('chat')
      setInput('')
      updateAttachments((current) => {
        for (const attachment of current) {
          void window.goodbuddy.context.remove(attachment.id)
        }
        return []
      })
      requestAnimationFrame(() => inputRef.current?.focus())
      return true
    },
    [notify, projects, runtimeSettings, updateAttachments]
  )
  const activeProject = useMemo(
    () => projects.find((project) => project.id === activeProjectId),
    [activeProjectId, projects]
  )
  const filteredConversations = useMemo(() => {
    const query = searchQuery.trim().toLocaleLowerCase()
    return conversations.filter(
      (conversation) =>
        (!activeProjectId ||
          conversation.projectId === activeProjectId) &&
        (activeProject?.kind !== 'channel' ||
          conversation.remote !== undefined) &&
        (!query ||
        conversation.title.toLocaleLowerCase().includes(query) ||
        conversation.messages.some((message) =>
          message.content.toLocaleLowerCase().includes(query)
        ))
    )
  }, [activeProject, activeProjectId, conversations, searchQuery])
  const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>(
    () =>
      conversations.flatMap((conversation) =>
        conversation.messages.flatMap((message) =>
          message.approval
            ? [
                {
                  conversationId: conversation.id,
                  messageId: message.id,
                  approvalId: message.approval.id,
                  title: message.approval.title,
                  description: message.approval.description,
                  toolName: message.approval.toolName
                }
              ]
            : []
        )
      ),
    [conversations]
  )
  const sidebarArtifacts = useMemo<SidebarArtifact[]>(
    () => {
      const persisted = assistantArtifacts
        .filter(
          (artifact) =>
            !activeProjectId || artifact.projectId === activeProjectId
        )
        .map((artifact) => ({
          id: artifact.id,
          title: artifact.title,
          content: artifact.content ?? '',
          createdAt: new Date(artifact.createdAt).getTime(),
          mimeType: artifact.mimeType
        }))
      if (persisted.length > 0) {
        return persisted
      }
      return (activeConversation?.messages ?? [])
        .filter(
          (message) =>
            message.role === 'assistant' &&
            message.state === 'complete' &&
            message.content.trim()
        )
        .slice(-20)
        .reverse()
        .map((message, index) => ({
          id: message.id,
          title:
            message.content
              .split(/\r?\n/, 1)[0]
              ?.replace(/^#+\s*/, '')
              .slice(0, 48) || `助手成果 ${index + 1}`,
          content: message.content,
          createdAt: message.createdAt,
          mimeType: 'text/markdown'
        }))
    },
    [activeConversation, activeProjectId, assistantArtifacts]
  )
  const enabledSidebarLibraries = useMemo(
    () =>
      knowledgeSnapshot.libraries.filter((library) =>
        enabledKnowledgeLibraryIds.includes(library.id)
      ),
    [enabledKnowledgeLibraryIds, knowledgeSnapshot.libraries]
  )
  const pendingHeartbeatSuggestionCount = useMemo(() => {
    const memoryIds = new Set(
      heartbeatEntries.flatMap((entry) => entry.proposedMemoryIds)
    )
    const taskIds = new Set(
      heartbeatEntries.flatMap((entry) => entry.followUpTaskIds)
    )
    return (
      assistantMemories.filter(
        (memory) =>
          memoryIds.has(memory.id) && memory.status === 'proposed'
      ).length +
      assistantTasks.filter(
        (task) =>
          taskIds.has(task.id) && task.status !== 'completed'
      ).length
    )
  }, [assistantMemories, assistantTasks, heartbeatEntries])

  const updateMessage = useCallback(
    (
      conversationId: string,
      messageId: string,
      update: (message: Message) => Message
    ): void => {
      setConversations((current) =>
        current.map((conversation) =>
          conversation.id === conversationId
            ? {
                ...conversation,
                updatedAt: Date.now(),
                messages: conversation.messages.map((message) =>
                  message.id === messageId ? update(message) : message
                )
              }
            : conversation
        )
      )
    },
    []
  )

  const recordActivity = useCallback(
    (
      record: Omit<ActivityRecord, 'id' | 'createdAt' | 'scope'>,
      scopeOverride?: ActivityRecord['scope']
    ): void => {
      const conversation = conversationsRef.current.find(
        (candidate) => candidate.id === record.conversationId
      )
      const project = conversation?.projectId
        ? projectsRef.current.find(
            (candidate) => candidate.id === conversation.projectId
          )
        : undefined
      const scope: ActivityRecord['scope'] =
        scopeOverride ??
        (!conversation
          ? { kind: 'unavailable' }
          : !conversation.projectId
            ? { kind: 'global' }
            : project?.id && project.name
              ? {
                  kind: 'project',
                  projectId: project.id.slice(0, 256),
                  projectName: project.name.slice(0, 120)
                }
              : { kind: 'unavailable' })
      setActivityRecords((current) =>
        upsertActivityRecord(current, {
          ...record,
          scope,
          id: crypto.randomUUID(),
          createdAt: Date.now()
        })
      )
    },
    []
  )

  const updateRequestActivity = useCallback(
    (
      requestId: string,
      status: ActivityRecord['status'],
      detail?: string
    ): void => {
      setActivityRecords((current) =>
        current.map((record) =>
          record.requestId === requestId && record.kind === 'request'
            ? {
                ...record,
                status,
                detail: detail?.slice(0, 4_000) ?? record.detail
              }
            : record
        )
      )
    },
    []
  )

  useEffect(() => {
    const api = window.goodbuddy.channels
    if (!api) {
      return
    }
    return api.onRemoteActivity((activity) => {
      if (activity.kind === 'result') {
        updateRequestActivity(
          activity.requestId,
          activity.status,
          activity.detail
        )
      }
      recordActivity(
        {
          requestId: activity.requestId,
          conversationId: activity.conversationId,
          callId: activity.callId,
          kind: activity.kind,
          title: activity.title,
          detail: activity.detail,
          status: activity.status
        },
        {
          kind: 'project',
          projectId: activity.projectId.slice(0, 256),
          projectName: activity.projectName.slice(0, 120)
        }
      )
    })
  }, [recordActivity, updateRequestActivity])

  const refreshKnowledge = useCallback(
    async (libraryId?: string): Promise<KnowledgeSnapshot> => {
      const requestId = ++knowledgeLoadRequestRef.current
      try {
        const snapshot =
          await window.goodbuddy.knowledge.getSnapshot(libraryId)
        if (requestId !== knowledgeLoadRequestRef.current) {
          return snapshot
        }
        failedKnowledgeLibraryIdRef.current = undefined
        setKnowledgeSnapshot(snapshot)
        setKnowledgeLoadError(undefined)
        if (!knowledgeScopeInitialized.current) {
          knowledgeScopeInitialized.current = true
          setEnabledKnowledgeLibraryIds(
            snapshot.libraries.map((library) => library.id)
          )
        } else {
          setEnabledKnowledgeLibraryIds((current) =>
            current.filter((id) =>
              snapshot.libraries.some((library) => library.id === id)
            )
          )
        }
        return snapshot
      } catch (reason) {
        if (requestId !== knowledgeLoadRequestRef.current) {
          throw reason
        }
        failedKnowledgeLibraryIdRef.current = libraryId
        setKnowledgeLoadError(
          displayErrorMessage(reason, '本地知识库读取失败')
        )
        throw reason
      }
    },
    []
  )

  const retryKnowledgeLoad = useCallback(async (): Promise<void> => {
    setKnowledgeLoading(true)
    setKnowledgeLoadError(undefined)
    try {
      await refreshKnowledge(failedKnowledgeLibraryIdRef.current)
    } catch {
      // The recoverable page state is set by refreshKnowledge.
    } finally {
      setKnowledgeLoading(false)
    }
  }, [refreshKnowledge])

  const switchRuntime = useCallback(
    async (selection: AgentRuntimeSelection): Promise<void> => {
      if (!runtimeSettings || !activeConversation || runtimeSwitching) {
        return
      }
      runtimeMenuButtonRef.current?.focus()
      setRuntimeSwitching(true)
      setRuntimeMenuOpen(false)
      const requestId = runtimeStatusRequestRef.current + 1
      runtimeStatusRequestRef.current = requestId
      try {
        const status = await window.goodbuddy.agent.getStatus(selection)
        if (runtimeStatusRequestRef.current !== requestId) {
          return
        }
        const selectionKey = agentRuntimeSelectionKey(selection)
        runtimeStatusCacheRef.current = {
          key: selectionKey,
          settings: runtimeSettings
        }
        const label = getRuntimeSelectionLabel(
          selection,
          runtimeSettings,
          status
        )
        setConversations((current) =>
          current.map((conversation) =>
            conversation.id === activeConversation.id
              ? {
                  ...conversation,
                  runtimeSelection: selection,
                  updatedAt: Date.now()
                }
              : conversation
          )
        )
        setRuntime(status)
        setRuntimeStatusKey(selectionKey)
        notify({
          tone: status.available ? 'success' : 'error',
          message: status.available
            ? `当前对话已切换到 ${label}`
            : `${label} 当前不可用：${status.detail}`,
          dedupeKey: 'runtime-switch'
        })
      } catch (reason) {
        if (runtimeStatusRequestRef.current !== requestId) {
          return
        }
        notify({
          tone: 'error',
          message:
            reason instanceof Error
              ? reason.message
              : 'Runtime 切换失败',
          dedupeKey: 'runtime-switch'
        })
      } finally {
        if (runtimeStatusRequestRef.current === requestId) {
          setRuntimeSwitching(false)
          requestAnimationFrame(() => {
            runtimeMenuButtonRef.current?.focus()
          })
        }
      }
    },
    [activeConversation, runtimeSettings, runtimeSwitching]
  )

  const refreshTokenUsage = useCallback(async (): Promise<void> => {
    setTokenUsage(await window.goodbuddy.usage.getTokenSummary())
  }, [])

  const loadWorkspaceChanges = useCallback(
    async (projectId: string): Promise<void> => {
      const requestId = workspaceChangesRequestRef.current + 1
      workspaceChangesRequestRef.current = requestId
      const changes = await window.goodbuddy.workspace.getChanges(projectId)
      if (
        workspaceChangesRequestRef.current === requestId &&
        activeProjectIdRef.current === projectId
      ) {
        setWorkspaceChanges(changes)
      }
    },
    []
  )

  const handleAgentEvent = useCallback(
    (event: AgentEvent): void => {
      const run = activeRuns.current.get(event.requestId)
      if (!run) {
        return
      }

      setAssistantTasks((current) =>
        current.map((task) =>
          task.id === event.requestId
            ? {
                ...task,
                status:
                  event.type === 'approval' || event.type === 'question'
                    ? 'waiting_approval'
                    : event.type === 'done'
                      ? 'completed'
                      : event.type === 'error'
                        ? event.status
                        : 'running',
                completedAt:
                  event.type === 'done' || event.type === 'error'
                    ? new Date().toISOString()
                    : task.completedAt,
                error:
                  event.type === 'error' ? event.message : task.error
              }
            : task
        )
      )
      if (event.type === 'done') {
        if (
          run.projectId &&
          activeProjectIdRef.current === run.projectId
        ) {
          void loadWorkspaceChanges(run.projectId).catch(() =>
            notify({
              tone: 'error',
              message: '工作区文件更改读取失败'
            })
          )
        }
        if (viewRef.current === 'activity') {
          void refreshTokenUsage().catch(() =>
            notify({
              tone: 'error',
              message: 'Token 用量读取失败'
            })
          )
        }
        void window.goodbuddy.artifacts
          .list()
          .then((artifacts) =>
            setAssistantArtifacts((current) =>
              mergeArtifacts(current, artifacts)
            )
          )
          .catch(() =>
            notify({ tone: 'error', message: '成果列表刷新失败' })
          )
      } else if (event.type === 'artifact') {
        hydratingArtifactIds.current.add(event.artifactId)
        void window.goodbuddy.artifacts
          .get(event.artifactId)
          .then((artifact) =>
            setAssistantArtifacts((current) =>
              mergeArtifacts(current, [artifact])
            )
          )
          .catch(() =>
            notify({ tone: 'error', message: '生成图片读取失败' })
          )
          .finally(() => {
            hydratingArtifactIds.current.delete(event.artifactId)
          })
      }

      if (event.type === 'text') {
        updateMessage(run.conversationId, run.messageId, (message) => {
          const remaining = Math.max(
            0,
            maxMessageContentLength - message.content.length
          )
          const acceptedDelta = event.delta.slice(0, remaining)
          return {
            ...message,
            content: `${message.content}${acceptedDelta}`,
            blocks: appendMessageContentBlock(
              message.blocks,
              'text',
              acceptedDelta
            ),
            status:
              event.delta.length > remaining
                ? '回答过长，已在本地截断显示'
                : undefined
          }
        })
      } else if (event.type === 'reasoning') {
        updateMessage(run.conversationId, run.messageId, (message) => {
          const currentReasoning = message.reasoning ?? ''
          const acceptedDelta = event.delta.slice(
            0,
            Math.max(
              0,
              maxMessageContentLength - currentReasoning.length
            )
          )
          return {
            ...message,
            reasoning: `${currentReasoning}${acceptedDelta}`,
            blocks: appendMessageContentBlock(
              message.blocks,
              'reasoning',
              acceptedDelta
            )
          }
        })
      } else if (event.type === 'status') {
        updateMessage(run.conversationId, run.messageId, (message) => ({
          ...message,
          status: event.message
        }))
      } else if (event.type === 'tool') {
        recordActivity({
          conversationId: run.conversationId,
          requestId: event.requestId,
          callId: event.callId.slice(0, 256),
          kind: 'tool',
          title: event.name,
          detail: [event.summary, event.error]
            .filter(Boolean)
            .join('\n')
            .slice(0, 4_000),
          status:
            event.state === 'pending'
              ? 'pending'
              : event.state === 'running'
                ? 'running'
                : event.state === 'failed'
                  ? 'failed'
                  : 'completed'
        })
        updateMessage(run.conversationId, run.messageId, (message) => {
          const tools = [...(message.tools ?? [])]
          const index = tools.findIndex(
            (tool) => tool.callId === event.callId.slice(0, 256)
          )
          const tool = {
            callId: event.callId.slice(0, 256),
            name: event.name,
            state: event.state,
            summary: event.summary,
            input: event.input,
            output: event.output,
            error: event.error
          }
          if (index >= 0) {
            tools[index] = tool
          } else {
            tools.push(tool)
          }
          return {
            ...message,
            tools,
            blocks: upsertMessageToolBlock(message.blocks, tool)
          }
        })
      } else if (event.type === 'subagent') {
        const childStatus = event.state
        const completedAt =
          event.state === 'completed' ||
          event.state === 'failed' ||
          event.state === 'cancelled'
            ? new Date().toISOString()
            : undefined
        setAssistantTasks((current) => {
          const existing = current.find(
            (task) => task.id === event.childTaskId
          )
          const childTask: AssistantTask = {
            id: event.childTaskId,
            projectId: run.projectId,
            conversationId: run.conversationId,
            parentTaskId: event.requestId,
            expertId: event.expertId,
            routingMode: event.routingMode,
            title: event.expertName,
            instructions:
              event.reason ?? `${event.expertName} 子专家任务`,
            origin: 'subagent',
            status: childStatus,
            createdAt:
              existing?.createdAt ?? new Date().toISOString(),
            startedAt:
              event.state === 'running'
                ? existing?.startedAt ?? new Date().toISOString()
                : existing?.startedAt,
            completedAt: completedAt ?? existing?.completedAt,
            error: event.error
          }
          return existing
            ? current.map((task) =>
                task.id === event.childTaskId ? childTask : task
              )
            : [...current, childTask].slice(0, 100)
        })
        recordActivity({
          conversationId: run.conversationId,
          requestId: event.requestId,
          callId: event.childTaskId,
          kind: 'subagent',
          title: event.expertName,
          detail: [
            event.routingMode === 'smart' ? '智能路由' : '手动指定',
            event.reason,
            event.error
          ]
            .filter(Boolean)
            .join(' · ')
            .slice(0, 4_000),
          status:
            event.state === 'queued'
              ? 'pending'
              : event.state
        })
        updateMessage(run.conversationId, run.messageId, (message) => {
          const subagents = [...(message.subagents ?? [])]
          const index = subagents.findIndex(
            (subagent) =>
              subagent.childTaskId === event.childTaskId
          )
          const subagent: SubagentActivity = {
            childTaskId: event.childTaskId,
            expertId: event.expertId,
            expertName: event.expertName,
            routingMode: event.routingMode,
            state: event.state,
            reason: event.reason,
            error: event.error
          }
          if (index >= 0) {
            subagents[index] = subagent
          } else if (subagents.length < 3) {
            subagents.push(subagent)
          }
          return { ...message, subagents }
        })
      } else if (event.type === 'approval') {
        recordActivity({
          conversationId: run.conversationId,
          requestId: event.requestId,
          kind: 'approval',
          title: event.title,
          detail: event.description.slice(0, 4_000),
          status: 'pending'
        })
        updateMessage(run.conversationId, run.messageId, (message) => ({
          ...message,
          status: undefined,
          approval: {
            id: event.approvalId,
            title: event.title,
            description: event.description,
            toolName: event.toolName,
            argumentSummary: event.argumentSummary,
            allowPermanent: event.allowPermanent
          }
        }))
      } else if (event.type === 'question') {
        updateMessage(run.conversationId, run.messageId, (message) => ({
          ...message,
          status: undefined,
          question: event
        }))
      } else if (event.type === 'artifact') {
        updateMessage(run.conversationId, run.messageId, (message) => ({
          ...message,
          artifactIds: [
            ...new Set([...(message.artifactIds ?? []), event.artifactId])
          ].slice(-8),
          status: '图片已生成，正在保存结果'
        }))
      } else if (event.type === 'source-references') {
        updateMessage(run.conversationId, run.messageId, (message) => {
          const referenceKey = (
            reference: KnowledgeSearchReference
          ): string =>
            [
              reference.libraryId,
              reference.documentId,
              reference.locator ?? '',
              reference.snippet
            ].join('\0')
          const incoming = [
            ...new Map(
              event.references.map((reference) => [
                referenceKey(reference),
                reference
              ])
            ).values()
          ]
          const incomingKeys = new Set(incoming.map(referenceKey))
          const references = [
            ...incoming,
            ...(message.sourceReferences ?? []).filter(
              (reference) => !incomingKeys.has(referenceKey(reference))
            )
          ].slice(0, 20)
          const referenceSources = references.map(
            (reference) =>
              `${reference.libraryName} / ${reference.documentName}${
                reference.locator ? ` (${reference.locator})` : ''
              }`
          )
          return {
            ...message,
            sourceReferences: references,
            sources: [
              ...new Set([
                ...referenceSources,
                ...(message.sources ?? [])
              ])
            ].slice(0, 100)
          }
        })
      } else {
        const terminalStatus =
          event.type === 'error'
            ? event.status === 'cancelled'
              ? 'cancelled'
              : 'failed'
            : 'completed'
        updateRequestActivity(
          event.requestId,
          terminalStatus,
          event.type === 'error' ? event.message : '任务执行完成'
        )
        if (event.type === 'error') {
          setActivityRecords((current) =>
            current.map((record) =>
              record.requestId === event.requestId &&
              record.kind !== 'request' &&
              (record.status === 'pending' ||
                record.status === 'running')
                ? {
                    ...record,
                    status: terminalStatus,
                    detail: `${record.detail}\n${event.message}`.slice(
                      0,
                      4_000
                    )
                  }
                : record
            )
          )
        }
        recordActivity({
          conversationId: run.conversationId,
          requestId: event.requestId,
          kind: 'result',
          title: event.type === 'error' ? '任务执行失败' : '任务执行完成',
          detail:
            event.type === 'error'
              ? event.message.slice(0, 4_000)
              : 'Agent Runtime 已完成响应',
          status: terminalStatus
        })
        updateMessage(run.conversationId, run.messageId, (message) => {
          const toolTerminalState =
            event.type === 'error'
              ? event.status === 'cancelled'
                ? ('cancelled' as const)
                : ('failed' as const)
              : undefined
          const fallbackError =
            event.type === 'error' && !message.content
              ? event.message.slice(0, maxMessageContentLength)
              : ''
          return {
            ...message,
            state: event.type === 'error' ? 'error' : 'complete',
            status: event.type === 'error' ? event.message : undefined,
            approval: undefined,
            question: undefined,
            tools: toolTerminalState
              ? message.tools?.map((tool) =>
                  tool.state === 'pending' || tool.state === 'running'
                    ? { ...tool, state: toolTerminalState }
                    : tool
                )
              : message.tools,
            blocks: toolTerminalState
              ? terminalizeMessageToolBlocks(
                  appendMessageContentBlock(
                    message.blocks,
                    'text',
                    fallbackError
                  ),
                  toolTerminalState
                )
              : appendMessageContentBlock(
                  message.blocks,
                  'text',
                  fallbackError
                ),
            content: fallbackError || message.content
          }
        })
        activeRuns.current.delete(event.requestId)
      }
    },
    [
      recordActivity,
      loadWorkspaceChanges,
      refreshTokenUsage,
      updateMessage,
      updateRequestActivity
    ]
  )

  useEffect(() => {
    activeProjectIdRef.current = activeProjectId
    if (activeProjectId) {
      try {
        localStorage.setItem(activeProjectStorageKey, activeProjectId)
      } catch {
        // The project selection still works when persistence is unavailable.
      }
    }
  }, [activeProjectId])

  useEffect(() => {
    viewRef.current = view
  }, [view])

  useEffect(() => {
    if (!conversationStoreReady) {
      return
    }
    const timeout = setTimeout(() => {
      void window.goodbuddy.conversations
        .replace(toConversationSnapshots(conversations))
        .catch(() => {
          notify({
            tone: 'error',
            message: '会话持久化失败，请检查本地存储',
            dedupeKey: 'conversation-persistence'
          })
        })
    }, 500)
    return () => clearTimeout(timeout)
  }, [conversationStoreReady, conversations])

  useEffect(() => {
    if (!conversationStoreReady) {
      return
    }
    let active = true
    let refreshSequence = 0
    const remove = window.goodbuddy.conversations.onChanged(() => {
      const sequence = ++refreshSequence
      void window.goodbuddy.conversations
        .list()
        .then((persisted) => {
          if (!active || sequence !== refreshSequence) {
            return
          }
          const remote = persisted.filter(
            (conversation) => conversation.remote
          )
          const previousById = new Map(
            conversationsRef.current.map((conversation) => [
              conversation.id,
              conversation
            ])
          )
          const updated = remote.filter((conversation) => {
            const previous = previousById.get(conversation.id)
            return (
              previous === undefined ||
              conversation.updatedAt > previous.updatedAt
            )
          })
          const unread = updated.filter(
            (conversation) =>
              conversation.id !== activeConversationIdRef.current
          )
          if (unread.length > 0) {
            setUnreadConversationIds((current) => {
              const next = new Set(current)
              unread.forEach((conversation) =>
                next.add(conversation.id)
              )
              return next
            })
            notify({
              tone: 'info',
              message: `${
                projectChannelLabels[
                  unread[0]!.remote!.channel
                ]
              } 收到新消息`,
              dedupeKey: 'remote-channel-message'
            })
          }
          const local = conversationsRef.current.filter(
            (conversation) => !conversation.remote
          )
          setConversations(
            [...remote, ...local].sort(
              (left, right) => right.updatedAt - left.updatedAt
            )
          )
        })
        .catch(() => {
          if (active) {
            notify({
              tone: 'error',
              message: '远程通道会话刷新失败',
              dedupeKey: 'remote-conversation-refresh'
            })
          }
        })
    })
    return () => {
      active = false
      remove()
    }
  }, [conversationStoreReady])

  useEffect(() => {
    saveActivityRecords(activityRecords)
  }, [activityRecords])

  useEffect(() => {
    let active = true
    void Promise.all([
      window.goodbuddy.projects.list(false),
      window.goodbuddy.conversations.list()
    ])
      .then(async ([value, persistedConversations]) => {
        if (!active || value.length === 0) {
          return
        }
        const lastActiveProjectId = loadActiveProjectId()
        const project =
          value.find(
            (candidate) => candidate.id === lastActiveProjectId
          ) ?? value[0]!
        setProjects(value)
        setActiveProjectId(project.id)
        setWorkMode(
          normalizeInteractiveWorkMode(project.defaultWorkMode)
        )
        let nextConversations: Conversation[] =
          persistedConversations.length > 0
            ? persistedConversations
            : migrationConversations.current.map((conversation) =>
            conversation.projectId || project.kind === 'channel'
              ? conversation
              : { ...conversation, projectId: project.id }
          )
        let projectConversation = nextConversations.find(
          (conversation) =>
            conversation.projectId === project.id &&
            (project.kind !== 'channel' ||
              conversation.remote !== undefined)
        )
        if (!projectConversation && project.kind !== 'channel') {
          projectConversation = createConversation(project.id)
          nextConversations = [
            projectConversation,
            ...nextConversations
          ]
        }
        if (persistedConversations.length === 0) {
          await window.goodbuddy.conversations.replace(
            toConversationSnapshots(nextConversations)
          )
        }
        if (!active) {
          return
        }
        setConversations(nextConversations)
        setActiveId(projectConversation?.id ?? '')
        localStorage.removeItem(storageKey)
        setConversationStoreReady(true)
      })
      .catch((reason: unknown) => {
        if (active) {
          notify({
            tone: 'error',
            message:
              reason instanceof Error ? reason.message : '项目读取失败'
          })
        }
      })
    return () => {
      active = false
    }
  }, [])

  useEffect(() => {
    void window.goodbuddy.memory
      .list(activeProjectId || undefined)
      .then(setAssistantMemories)
      .catch(() =>
        notify({ tone: 'error', message: '长期记忆读取失败' })
      )
  }, [activeProjectId])

  const refreshWorkspaceChanges = useCallback(async (): Promise<void> => {
    if (!activeProjectId) {
      workspaceChangesRequestRef.current += 1
      setWorkspaceChanges(undefined)
      return
    }
    await loadWorkspaceChanges(activeProjectId)
  }, [activeProjectId, loadWorkspaceChanges])

  const listWorkspaceDirectory = useCallback(
    async (path: string) => {
      if (!activeProjectId) {
        throw new Error('请先选择项目')
      }
      return window.goodbuddy.workspace.listDirectory(activeProjectId, path)
    },
    [activeProjectId]
  )

  const loadWorkspaceFile = useCallback(
    async (path: string) => {
      if (!activeProjectId) {
        throw new Error('请先选择项目')
      }
      return window.goodbuddy.workspace.readFile(activeProjectId, path)
    },
    [activeProjectId]
  )
  const openWorkspaceEntry = useCallback(
    async (
      path: string,
      type: 'file' | 'directory'
    ): Promise<void> => {
      if (!activeProjectId) {
        throw new Error('请先选择项目')
      }
      await window.goodbuddy.workspace.openPath(
        activeProjectId,
        path,
        type
      )
    },
    [activeProjectId]
  )

  useEffect(() => {
    if (assistantSidebarTab !== 'workspace') {
      return
    }
    const timeout = setTimeout(() => {
      void refreshWorkspaceChanges().catch(() => {
        notify({
          tone: 'error',
          message: '工作区文件更改读取失败'
        })
      })
    }, 0)
    return () => clearTimeout(timeout)
  }, [assistantSidebarTab, refreshWorkspaceChanges])

  useEffect(() => {
    void window.goodbuddy.experts
      .list()
      .then(setAssistantExperts)
      .catch(() =>
        notify({ tone: 'error', message: '专家角色读取失败' })
      )
  }, [])

  useEffect(() => {
    void window.goodbuddy.schedules
      .list(activeProjectId || undefined)
      .then(setAssistantSchedules)
      .catch(() =>
        notify({ tone: 'error', message: '定时任务读取失败' })
      )
  }, [activeProjectId])

  const loadHeartbeats = useCallback(async () => {
    const allConfigs = await window.goodbuddy.heartbeats.list()
    const configs = allConfigs.filter(
      (config) =>
        !config.projectId || config.projectId === activeProjectId
    )
    const histories = await Promise.all(
      configs.map((config) =>
        window.goodbuddy.heartbeats.history(config.id)
      )
    )
    const runs = new Map(
      histories
        .flatMap((history) => history.runs)
        .map((run) => [run.id, run])
    )
    const entries = new Map(
      histories
        .flatMap((history) => history.entries)
        .map((entry) => [entry.id, entry])
    )
    return {
      configs,
      runs: [...runs.values()],
      entries: [...entries.values()]
    }
  }, [activeProjectId])

  const refreshHeartbeats = useCallback(async (): Promise<void> => {
    const requestId = ++heartbeatLoadRequestRef.current
    const result = await loadHeartbeats()
    if (requestId !== heartbeatLoadRequestRef.current) {
      return
    }
    setAssistantHeartbeats(result.configs)
    setHeartbeatRuns(result.runs)
    setHeartbeatEntries(result.entries)
  }, [loadHeartbeats])

  useEffect(() => {
    const requestId = ++heartbeatLoadRequestRef.current
    const timeout = setTimeout(() => {
      if (requestId !== heartbeatLoadRequestRef.current) {
        return
      }
      setHeartbeatLoading(true)
      setHeartbeatLoadError(undefined)
      setAssistantHeartbeats([])
      setHeartbeatRuns([])
      setHeartbeatEntries([])
      void loadHeartbeats()
        .then((result) => {
          if (requestId !== heartbeatLoadRequestRef.current) {
            return
          }
          setAssistantHeartbeats(result.configs)
          setHeartbeatRuns(result.runs)
          setHeartbeatEntries(result.entries)
          setHeartbeatLoadError(undefined)
        })
        .catch((reason: unknown) => {
          if (requestId !== heartbeatLoadRequestRef.current) {
            return
          }
          setHeartbeatLoadError(
            displayErrorMessage(reason, '智能心跳读取失败')
          )
        })
        .finally(() => {
          if (requestId === heartbeatLoadRequestRef.current) {
            setHeartbeatLoading(false)
          }
        })
    }, 0)
    return () => {
      clearTimeout(timeout)
      if (requestId === heartbeatLoadRequestRef.current) {
        heartbeatLoadRequestRef.current += 1
      }
    }
  }, [loadHeartbeats])

  const refreshHeartbeatCenter = useCallback(async (): Promise<void> => {
    const projectId = activeProjectId
    const [memories, tasks, artifacts] = await Promise.all([
      window.goodbuddy.memory.list(projectId || undefined),
      window.goodbuddy.tasks.list(),
      window.goodbuddy.artifacts.list(projectId || undefined),
      refreshHeartbeats()
    ])
    if (activeProjectIdRef.current !== projectId) {
      return
    }
    setAssistantMemories(memories)
    setAssistantTasks(tasks)
    setAssistantArtifacts((current) =>
      mergeArtifacts(current, artifacts)
    )
  }, [activeProjectId, refreshHeartbeats])

  const retryHeartbeatLoad = useCallback(async (): Promise<void> => {
    setHeartbeatLoading(true)
    setHeartbeatLoadError(undefined)
    try {
      await refreshHeartbeatCenter()
      setHeartbeatLoadError(undefined)
    } catch (reason) {
      setHeartbeatLoadError(
        displayErrorMessage(reason, '智能心跳读取失败')
      )
    } finally {
      setHeartbeatLoading(false)
    }
  }, [refreshHeartbeatCenter])

  const createHeartbeat = useCallback(
    async (input: HeartbeatCreateInput): Promise<void> => {
      const projectId = activeProjectId
      await window.goodbuddy.heartbeats.create({
        ...input,
        projectId: projectId || undefined
      })
      if (activeProjectIdRef.current === projectId) {
        await refreshHeartbeats()
      }
    },
    [activeProjectId, refreshHeartbeats]
  )

  const removeHeartbeat = useCallback(
    async (heartbeatId: string): Promise<void> => {
      const projectId = activeProjectId
      await window.goodbuddy.heartbeats.remove(heartbeatId)
      if (activeProjectIdRef.current === projectId) {
        await refreshHeartbeats()
      }
    },
    [activeProjectId, refreshHeartbeats]
  )

  const runHeartbeat = useCallback(
    async (heartbeatId: string): Promise<void> => {
      const projectId = activeProjectId
      await window.goodbuddy.heartbeats.runNow(heartbeatId)
      if (activeProjectIdRef.current !== projectId) {
        return
      }
      await refreshHeartbeatCenter()
    },
    [activeProjectId, refreshHeartbeatCenter]
  )

  const setHeartbeatPaused = useCallback(
    async (heartbeatId: string, paused: boolean): Promise<void> => {
      const projectId = activeProjectId
      await window.goodbuddy.heartbeats.setPaused(heartbeatId, paused)
      if (activeProjectIdRef.current === projectId) {
        await refreshHeartbeats()
      }
    },
    [activeProjectId, refreshHeartbeats]
  )

  useEffect(() => {
    if (view !== 'heartbeat') {
      return
    }
    let refreshing = false
    const refresh = (): void => {
      if (refreshing) {
        return
      }
      refreshing = true
      void refreshHeartbeatCenter()
        .then(() => setHeartbeatLoadError(undefined))
        .catch((reason: unknown) =>
          setHeartbeatLoadError(
            displayErrorMessage(reason, '智能心跳刷新失败')
          )
        )
        .finally(() => {
          refreshing = false
        })
    }
    const timeout = setTimeout(refresh, 0)
    const interval = setInterval(refresh, 30_000)
    return () => {
      clearTimeout(timeout)
      clearInterval(interval)
    }
  }, [refreshHeartbeatCenter, view])

  useEffect(() => {
    void window.goodbuddy.tasks
      .list()
      .then((tasks) => {
        setAssistantTasks(tasks)
        setActivityRecords((current) =>
          reconcileActivityRecords(
            current,
            tasks,
            new Set(activeRuns.current.keys())
          )
        )
      })
      .catch(() =>
        notify({ tone: 'error', message: '历史任务读取失败' })
      )
  }, [])

  useEffect(() => {
    if (view !== 'activity') {
      return
    }
    const timeout = setTimeout(() => {
      void refreshTokenUsage().catch(() =>
        notify({ tone: 'error', message: 'Token 用量读取失败' })
      )
    }, 0)
    return () => clearTimeout(timeout)
  }, [refreshTokenUsage, view])

  useEffect(() => {
    void window.goodbuddy.artifacts
      .list()
      .then((artifacts) =>
        setAssistantArtifacts((current) =>
          mergeArtifacts(current, artifacts)
        )
      )
      .catch(() =>
        notify({ tone: 'error', message: '历史成果读取失败' })
      )
  }, [])

  useEffect(() => {
    const missingIds = [
      ...new Set(
        (activeConversation?.messages ?? []).flatMap(
          (message) => message.artifactIds ?? []
        )
      )
    ]
      .filter(
        (artifactId) =>
          !assistantArtifactById.get(artifactId)?.content &&
          !hydratingArtifactIds.current.has(artifactId)
      )
      .slice(-32)
    if (missingIds.length === 0) {
      return
    }
    for (const artifactId of missingIds) {
      hydratingArtifactIds.current.add(artifactId)
    }
    void Promise.allSettled(
      missingIds.map((artifactId) =>
        window.goodbuddy.artifacts.get(artifactId)
      )
    ).then((results) => {
      const artifacts = results.flatMap((result) =>
        result.status === 'fulfilled' ? [result.value] : []
      )
      if (artifacts.length > 0) {
        setAssistantArtifacts((current) =>
          mergeArtifacts(current, artifacts)
        )
      }
      for (const artifactId of missingIds) {
        hydratingArtifactIds.current.delete(artifactId)
      }
    })
  }, [activeConversation, assistantArtifactById])

  useEffect(() => {
    const timeout = setTimeout(() => {
      void refreshKnowledge()
        .catch(() => {
          // refreshKnowledge exposes a recoverable page-local error.
        })
        .finally(() => setKnowledgeLoading(false))
    }, 0)
    return () => clearTimeout(timeout)
  }, [refreshKnowledge])

  useEffect(() => {
    void Promise.all([
      window.goodbuddy.settings.getRuntime(),
      window.goodbuddy.agent.getStatus()
    ])
      .then(([settings, status]) => {
        const selectionKey = agentRuntimeSelectionKey(
          getDefaultRuntimeSelection(settings)
        )
        runtimeStatusCacheRef.current = {
          key: selectionKey,
          settings
        }
        setRuntimeSettings(settings)
        setRuntime(status)
        setRuntimeStatusKey(selectionKey)
        if (!status.available && !runtimeSetupPromptedRef.current) {
          runtimeSetupPromptedRef.current = true
          setView('settings')
        }
      })
      .catch(() =>
        notify({ tone: 'error', message: 'Runtime 设置读取失败' })
      )
    void window.goodbuddy.app
      .getInfo()
      .then(setAppInfo)
      .catch(() =>
        notify({ tone: 'error', message: '应用信息读取失败' })
      )
    const removeAgentListener =
      window.goodbuddy.agent.onEvent(handleAgentEvent)
    const removeOpenSettingsListener =
      window.goodbuddy.app.onOpenSettings(() => setView('settings'))
    return () => {
      removeAgentListener()
      removeOpenSettingsListener()
    }
  }, [handleAgentEvent])

  useEffect(
    () => {
      const browserApi = window.goodbuddy.browser
      if (!browserApi) {
        return
      }
      return browserApi.onState((state) => {
        setBrowserStates((current) => {
          const previous = current[state.conversationId]
          return {
            ...current,
            [state.conversationId]:
              state.status === 'stopped' ||
              state.frameDataUrl ||
              !previous?.frameDataUrl
                ? state
                : {
                    ...state,
                    frameDataUrl: previous.frameDataUrl
                  }
          }
        })
        if (
          state.status !== 'stopped' &&
          state.conversationId ===
            conversationNavigationRef.current.activeId
        ) {
          setAssistantSidebarOpen(true)
          setAssistantSidebarTab('browser')
        }
      })
    },
    []
  )

  useEffect(
    () =>
      window.goodbuddy.app.onNewConversation(() => {
        startNewConversation(activeProjectIdRef.current || undefined)
      }),
    [startNewConversation]
  )

  useEffect(() => {
    const handleNewConversationShortcut = (
      event: KeyboardEvent
    ): void => {
      if (
        event.key.toLocaleLowerCase() !== 'n' ||
        (!event.ctrlKey && !event.metaKey) ||
        event.altKey ||
        event.shiftKey
      ) {
        return
      }
      event.preventDefault()
      startNewConversation(activeProjectIdRef.current || undefined)
    }
    document.addEventListener(
      'keydown',
      handleNewConversationShortcut
    )
    return () =>
      document.removeEventListener(
        'keydown',
        handleNewConversationShortcut
      )
  }, [startNewConversation])

  useEffect(() => {
    const frame = requestAnimationFrame(() => {
      scrollRef.current?.scrollTo({
        top: scrollRef.current.scrollHeight,
        behavior: 'auto'
      })
    })
    return () => cancelAnimationFrame(frame)
  }, [activeConversation?.messages])

  const selectProject = (projectId: string): void => {
    const project = projects.find((candidate) => candidate.id === projectId)
    if (!project) {
      return
    }
    setActiveProjectId(projectId)
    setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode))
    const conversation = conversations.find(
      (candidate) =>
        candidate.projectId === projectId &&
        (project.kind !== 'channel' || candidate.remote !== undefined)
    )
    if (conversation) {
      setActiveId(conversation.id)
    } else if (project.kind === 'channel') {
      setActiveId('')
    } else {
      const created = createConversation(
        projectId,
        runtimeSettings
          ? getDefaultRuntimeSelection(runtimeSettings)
          : undefined
      )
      setConversations((current) => [created, ...current])
      setActiveId(created.id)
    }
    setView('chat')
  }

  const createProject = async (
    input: ProjectCreateInput
  ): Promise<AssistantProject> => {
    const project = await window.goodbuddy.projects.create(input)
    setProjects((current) => [project, ...current])
    setActiveProjectId(project.id)
    setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode))
    const conversation = createConversation(
      project.id,
      runtimeSettings
        ? getDefaultRuntimeSelection(runtimeSettings)
        : undefined
    )
    setConversations((current) => [conversation, ...current])
    setActiveId(conversation.id)
    setView('chat')
    return project
  }

  const updateProject = async (
    projectId: string,
    input: ProjectCreateInput
  ): Promise<AssistantProject> => {
    const project = await window.goodbuddy.projects.update(
      projectId,
      input
    )
    setProjects((current) =>
      current.map((candidate) =>
        candidate.id === project.id ? project : candidate
      )
    )
    if (project.id === activeProjectId) {
      setWorkMode(
        normalizeInteractiveWorkMode(project.defaultWorkMode)
      )
    }
    return project
  }

  const archiveProject = async (projectId: string): Promise<void> => {
    await window.goodbuddy.projects.setArchived(projectId, true)
    const remaining = projects.filter((project) => project.id !== projectId)
    setProjects(remaining)
    const next = remaining[0]
    if (next) {
      selectProject(next.id)
    }
  }

  const deleteProject = async (
    projectId: string,
    confirmation: string
  ): Promise<void> => {
    await window.goodbuddy.projects.delete(projectId, confirmation)
    const remainingProjects = projects.filter(
      (project) => project.id !== projectId
    )
    const remainingConversations = conversations.filter(
      (conversation) => conversation.projectId !== projectId
    )
    setProjects(remainingProjects)
    setConversations(remainingConversations)
    setAssistantTasks((current) =>
      current.filter((task) => task.projectId !== projectId)
    )
    setAssistantArtifacts((current) =>
      current.filter((artifact) => artifact.projectId !== projectId)
    )
    setAssistantMemories((current) =>
      current.filter(
        (memory) =>
          !(
            memory.scope === 'project' &&
            memory.scopeId === projectId
          )
      )
    )
    setAssistantSchedules((current) =>
      current.filter((schedule) => schedule.projectId !== projectId)
    )
    setAssistantHeartbeats((current) =>
      current.filter((heartbeat) => heartbeat.projectId !== projectId)
    )
    const next = remainingProjects[0]
    if (next) {
      setActiveProjectId(next.id)
      setWorkMode(
        normalizeInteractiveWorkMode(next.defaultWorkMode)
      )
      const nextConversation = remainingConversations.find(
        (conversation) =>
          conversation.projectId === next.id &&
          (next.kind !== 'channel' ||
            conversation.remote !== undefined)
      )
      if (nextConversation) {
        setActiveId(nextConversation.id)
      } else if (next.kind === 'channel') {
        setActiveId('')
      } else {
        const created = createConversation(
          next.id,
          runtimeSettings
            ? getDefaultRuntimeSelection(runtimeSettings)
            : undefined
        )
        setConversations((current) => [created, ...current])
        setActiveId(created.id)
      }
    }
    setView('chat')
  }

  const newConversation = (): boolean => {
    return startNewConversation(activeProjectId || undefined)
  }

  const setMemoryStatus = async (
    memoryId: string,
    status: AssistantMemory['status']
  ): Promise<void> => {
    await window.goodbuddy.memory.setStatus(memoryId, status)
    setAssistantMemories((current) =>
      status === 'rejected'
        ? current.filter((memory) => memory.id !== memoryId)
        : current.map((memory) =>
            memory.id === memoryId ? { ...memory, status } : memory
          )
    )
  }

  const useHeartbeatTask = (task: AssistantTask): void => {
    if (!newConversation()) {
      return
    }
    setWorkMode('ask')
    setInput(
      [
        '请根据以下智能心跳建议制定可执行方案：',
        task.title,
        task.instructions
      ].join('\n\n')
    )
    notify({
      tone: 'info',
      message: `已将“${task.title}”带入对话，请确认后发送`
    })
    requestAnimationFrame(() => inputRef.current?.focus())
  }

  const setHeartbeatTaskStatus = async (
    taskId: string,
    status: 'completed' | 'cancelled'
  ): Promise<void> => {
    await window.goodbuddy.tasks.setStatus(taskId, status)
    setAssistantTasks((current) =>
      current.map((task) =>
        task.id === taskId
          ? {
              ...task,
              status,
              completedAt: new Date().toISOString()
            }
          : task
      )
    )
  }

  const deleteConversation = async (
    conversationId: string
  ): Promise<void> => {
    if (deletingConversationId) {
      return
    }
    setDeletingConversationId(conversationId)
    const activeRequests = [...activeRuns.current.entries()]
      .filter(([, run]) => run.conversationId === conversationId)
      .map(([requestId]) => requestId)
    try {
      await Promise.all(
        activeRequests.map((requestId) =>
          window.goodbuddy.agent.cancel(requestId)
        )
      )
    } catch {
      notify({
        tone: 'error',
        message: '停止会话中的运行任务失败，尚未删除对话'
      })
      setDeletingConversationId('')
      return
    }
    setConfirmingConversationId('')
    setDeletingConversationId('')
    if (conversationActionsId === conversationId) {
      setConversationActionsId('')
    }
    if (renamingConversationId === conversationId) {
      setRenamingConversationId('')
    }
    const browserStop = window.goodbuddy.browser?.stop(conversationId)
    if (browserStop) {
      void browserStop.catch(() => {
        notify({
          tone: 'error',
          message: '关闭已删除对话的浏览器失败'
        })
      })
    }
    setBrowserStates((current) => {
      const next = { ...current }
      delete next[conversationId]
      return next
    })
    const remaining = conversations.filter(
      (conversation) => conversation.id !== conversationId
    )
    const projectRemaining = remaining.filter(
      (conversation) => conversation.projectId === activeProjectId
    )
    setConversations(remaining)
    if (projectRemaining.length > 0) {
      if (conversationId === activeId) {
        setActiveId(projectRemaining[0]?.id ?? '')
      }
      return
    }
    if (activeProject?.kind === 'channel') {
      setActiveId('')
      return
    }
    const replacement = createConversation(
      activeProjectId || undefined,
      runtimeSettings
        ? getDefaultRuntimeSelection(runtimeSettings)
        : undefined
    )
    setConversations((current) => [replacement, ...current])
    setActiveId(replacement.id)
  }

  const focusConversationActions = (conversationId: string): void => {
    requestAnimationFrame(() =>
      conversationActionTriggerRefs.current.get(conversationId)?.focus()
    )
  }

  const saveTitle = (
    conversationId: string,
    titleInput: string
  ): void => {
    const title = titleInput.trim().slice(0, 80)
    if (!title) {
      return
    }
    setConversations((current) =>
      current.map((conversation) =>
        conversation.id === conversationId
          ? { ...conversation, title, updatedAt: Date.now() }
          : conversation
      )
    )
    setRenamingConversationId('')
    focusConversationActions(conversationId)
  }

  const copyConversation = async (
    conversation: ConversationSnapshot
  ): Promise<void> => {
    const transcript = conversation.messages
      .map(
        (message) =>
          `${message.role === 'user' ? '用户' : 'GoodBuddy'}：\n${message.content}${formatAttachmentList(message.attachments)}`
      )
      .join('\n\n')
    try {
      await navigator.clipboard.writeText(transcript)
      notify({ tone: 'success', message: '对话已复制到剪贴板' })
    } catch {
      notify({
        tone: 'error',
        message: '无法访问剪贴板，请检查系统权限'
      })
    }
  }

  const exportConversation = (
    conversation: ConversationSnapshot
  ): void => {
    const markdown = [
      `# ${conversation.title}`,
      '',
      ...conversation.messages.flatMap((message) => [
        `## ${message.role === 'user' ? '用户' : 'GoodBuddy'}`,
        '',
        `${message.content}${formatAttachmentList(message.attachments)}`,
        ''
      ])
    ].join('\n')
    const blob = new Blob([markdown], {
      type: 'text/markdown;charset=utf-8'
    })
    const url = URL.createObjectURL(blob)
    const anchor = document.createElement('a')
    anchor.href = url
    anchor.download = `${conversation.title.replace(/[\\/:*?"<>|]/g, '_') || 'GoodBuddy 对话'}.md`
    anchor.click()
    URL.revokeObjectURL(url)
    notify({ tone: 'success', message: '对话已导出' })
  }

  const openImageViewer = (
    item: ImageViewerItem,
    trigger: HTMLElement
  ): void => {
    if (!imageDataUrlPattern.test(item.src)) {
      notify({ tone: 'error', message: '图片内容不可用' })
      return
    }
    imageViewerTriggerRef.current = trigger
    setImageViewerItem(item)
  }

  const closeImageViewer = (): void => {
    setImageViewerItem(undefined)
    requestAnimationFrame(() => {
      imageViewerTriggerRef.current?.focus()
      imageViewerTriggerRef.current = undefined
    })
  }

  const downloadImage = (item: ImageViewerItem): void => {
    if (!imageDataUrlPattern.test(item.src)) {
      notify({ tone: 'error', message: '图片内容不可用' })
      return
    }
    const anchor = document.createElement('a')
    anchor.href = item.src
    anchor.download = getImageDownloadName(item.title, item.src)
    anchor.rel = 'noopener'
    anchor.click()
    notify({ tone: 'info', message: '图片下载已开始' })
  }

  const submit = async (): Promise<void> => {
    const prompt = input.trim()
    if (!prompt || !activeConversation) {
      return
    }
    if (activeConversation.remote) {
      notify({
        tone: 'info',
        message: '远程通道会话只能从对应消息应用继续发起'
      })
      return
    }
    if (!runtime) {
      notify({
        tone: 'info',
        message: 'Agent Runtime 正在加载，请稍后重试'
      })
      return
    }
    if (
      runtimeSwitching ||
      runtimeStatusKey !== activeRuntimeSelectionKey
    ) {
      notify({
        tone: 'info',
        message: 'Agent Runtime 状态正在更新，请稍后重试'
      })
      return
    }
    if (!runtime.available) {
      return
    }
    if (
      preparingConversations.current.has(activeConversation.id) ||
      [...activeRuns.current.values()].some(
        (run) => run.conversationId === activeConversation.id
      )
    ) {
      notify({
        tone: 'info',
        message: '当前对话已有任务正在运行，请等待完成或先停止'
      })
      return
    }

    const requestId = crypto.randomUUID()
    const conversationId = activeConversation.id
    const attachmentSnapshot = attachments.slice(0, 8)
    const historySnapshot = activeConversation.messages
    const projectIdSnapshot = activeProjectId || undefined
    const runtimeSelectionSnapshot = activeRuntimeSelection
    if (!runtimeSelectionSnapshot) {
      notify({ tone: 'info', message: '当前对话尚未选择 Runtime' })
      return
    }
    const selectedExpertSnapshot =
      runtime.capability === 'image-generation' ? '' : selectedExpertId
    const workModeSnapshot = effectiveWorkMode
    preparingConversations.current.add(conversationId)
    setInput('')
    updateAttachments([])
    const userMessage: Message = {
      id: crypto.randomUUID(),
      role: 'user',
      content: prompt,
      createdAt: Date.now(),
      state: 'complete',
      attachments:
        attachmentSnapshot.length > 0 ? attachmentSnapshot : undefined
    }
    setConversations((current) =>
      current.map((conversation) =>
        conversation.id === conversationId
          ? {
              ...conversation,
              title:
                conversation.title === '新对话'
                  ? prompt.slice(0, 24)
                  : conversation.title,
              updatedAt: Date.now(),
              messages: [
                ...conversation.messages.slice(-499),
                userMessage
              ]
            }
          : conversation
      )
    )
    const memoryContext =
      runtime.capability === 'image-generation'
        ? ''
        : buildMemoryContext(assistantMemories)
    const executionPrompt = memoryContext
      ? `${prompt}\n\n${memoryContext}`
      : prompt
    const assistantMessage: Message = {
      id: crypto.randomUUID(),
      role: 'assistant',
      content: '',
      blocks: [],
      createdAt: Date.now(),
      state: 'streaming',
      status: '正在连接 Agent Runtime'
    }

    activeRuns.current.set(requestId, {
      conversationId,
      messageId: assistantMessage.id,
      projectId: projectIdSnapshot
    })
    preparingConversations.current.delete(conversationId)
    const startedAt = new Date().toISOString()
    setAssistantTasks((current) =>
      [
        {
          id: requestId,
          projectId: projectIdSnapshot,
          conversationId,
          title: prompt.slice(0, 120),
          instructions: prompt,
          origin: 'user' as const,
          status: 'running' as const,
          createdAt: startedAt,
          startedAt
        },
        ...current
      ].slice(0, 100)
    )
    recordActivity({
      conversationId,
      requestId,
      kind: 'request',
      title: prompt.slice(0, 120),
      detail: '用户发起对话任务',
      status: 'running'
    })
    setConversations((current) =>
      current.map((conversation) =>
        conversation.id === conversationId
          ? {
              ...conversation,
              updatedAt: Date.now(),
              messages: [
                ...conversation.messages.slice(-499),
                assistantMessage
              ]
            }
          : conversation
      )
    )
    try {
      await window.goodbuddy.agent.run({
        requestId,
        conversationId,
        projectId: projectIdSnapshot,
        runtimeSelection: runtimeSelectionSnapshot,
        expertId:
          selectedExpertSnapshot && selectedExpertSnapshot !== 'team'
            ? selectedExpertSnapshot
            : undefined,
        teamMode: selectedExpertSnapshot === 'team',
        smartRouting:
          runtime.capability !== 'image-generation' &&
          runtimeSettings?.subagentSmartRoutingEnabled === true &&
          !selectedExpertSnapshot &&
          supportsSubagentSmartRouting(workModeSnapshot)
            ? true
            : undefined,
        workMode: workModeSnapshot,
        prompt: executionPrompt,
        knowledgeLibraryIds: enabledKnowledgeLibraryIds,
        contextIds: attachmentSnapshot.map(
          (attachment) => attachment.id
        ),
        history: historySnapshot
          .filter(
            (message) =>
              message.state === 'complete' && message.content.trim()
          )
          .slice(-30)
          .map((message) => ({
            role: message.role,
            content: message.content
          }))
      })
      for (const attachment of attachmentSnapshot) {
        void window.goodbuddy.context.remove(attachment.id)
      }
    } catch (error) {
      preparingConversations.current.delete(conversationId)
      for (const attachment of attachmentSnapshot) {
        void window.goodbuddy.context.remove(attachment.id)
      }
      handleAgentEvent({
        requestId,
        type: 'error',
        status: 'failed',
        message: error instanceof Error ? error.message : '发送失败'
      })
    }
  }

  const stop = async (): Promise<void> => {
    const requestId = [...activeRuns.current.entries()].find(
      ([, run]) => run.conversationId === activeId
    )?.[0]
    if (requestId) {
      try {
        await window.goodbuddy.agent.cancel(requestId)
      } catch {
        notify({ tone: 'error', message: '停止生成失败，请重试' })
      }
    }
  }

  const respondToApproval = async (
    conversationId: string,
    messageId: string,
    approvalId: string,
    decision: ApprovalDecision
  ): Promise<void> => {
    try {
      await window.goodbuddy.agent.respondApproval(approvalId, decision)
      const approved = decision !== 'deny'
      const decisionLabel = {
        deny: '拒绝',
        once: '仅此次允许',
        session: '此会话允许',
        permanent: '永久允许'
      }[decision]
      setActivityRecords((current) => {
        let updated = false
        return current.map((record) => {
          if (
            !updated &&
            record.conversationId === conversationId &&
            record.kind === 'approval' &&
            record.status === 'pending'
          ) {
            updated = true
            return {
              ...record,
              status: approved ? ('completed' as const) : ('denied' as const),
              detail: `${record.detail}\n用户选择了${decisionLabel}`
            }
          }
          return record
        })
      })
      updateMessage(conversationId, messageId, (message) => ({
        ...message,
        approval: undefined,
        status: approved
          ? `${decisionLabel}，Agent 正在执行`
          : '已拒绝工具执行'
      }))
    } catch {
      updateMessage(conversationId, messageId, (message) => ({
        ...message,
        status: '审批响应失败，请重试'
      }))
    }
  }

  const respondToQuestion = async (
    conversationId: string,
    messageId: string,
    questionId: string,
    answers?: AgentQuestionAnswer[]
  ): Promise<void> => {
    await window.goodbuddy.agent.respondQuestion(questionId, answers)
    updateMessage(conversationId, messageId, (message) => ({
      ...message,
      question: undefined,
      status: answers ? '回答已提交，OpenCode 正在继续执行' : '已跳过问题'
    }))
  }

  const addContext = async (
    action: () => Promise<ContextAttachment | ContextAttachment[]>
  ): Promise<void> => {
    setContextError(undefined)
    try {
      const result = await action()
      const selected = Array.isArray(result) ? result : [result]
      const current = attachmentsRef.current
      const unique = selected.filter(
        (item) =>
          !current.some((existing) => existing.id === item.id)
      )
      const accepted = unique.slice(
        0,
        Math.max(0, 8 - current.length)
      )
      for (const attachment of unique.slice(accepted.length)) {
        void window.goodbuddy.context.remove(attachment.id)
      }
      updateAttachments([...current, ...accepted])
      if (accepted.length < unique.length) {
        setContextError('单次消息最多添加 8 个附件')
      }
    } catch (reason) {
      setContextError(
        reason instanceof Error ? reason.message : '添加上下文失败'
      )
    }
  }

  const openWindowCapture = async (): Promise<void> => {
    setContextError(undefined)
    setWindowCaptureLoading(true)
    try {
      setWindowCaptureOptions(
        await window.goodbuddy.context.listWindows()
      )
    } catch (reason) {
      setContextError(
        reason instanceof Error ? reason.message : '读取应用窗口失败'
      )
    } finally {
      setWindowCaptureLoading(false)
    }
  }

  const captureSelectedWindow = async (
    sourceId: string
  ): Promise<void> => {
    setWindowCaptureOptions(undefined)
    await addContext(() =>
      window.goodbuddy.context.captureWindow(sourceId)
    )
  }

  const removeAttachment = (attachmentId: string): void => {
    void window.goodbuddy.context.remove(attachmentId)
    updateAttachments((current) =>
      current.filter((attachment) => attachment.id !== attachmentId)
    )
  }

  const startWebSpeechInput = async (): Promise<void> => {
    const SpeechRecognition =
      getSpeechRecognitionConstructor(window)
    if (!SpeechRecognition) {
      notify({
        tone: 'info',
        message: '当前系统不支持内置语音识别，可继续使用键盘输入'
      })
      return
    }
    setVoiceListening(true)
    setVoiceRecording(false)
    let started = false
    try {
      const prepared = await prepareSpeechRecognition(
        SpeechRecognition,
        'zh-CN',
        () => {
          notify({
            tone: 'info',
            message: '正在下载中文离线语音包，完成后将自动开始听写',
            dedupeKey: 'speech-status'
          })
        }
      )
      const { recognition } = prepared
      recognition.onresult = (event) => {
        const transcript = event.results[0]?.[0]?.transcript?.trim()
        if (transcript) {
          setInput((current) =>
            current ? `${current} ${transcript}` : transcript
          )
          notify({
            tone: 'success',
            message: '语音已转为文字，可编辑后发送',
            dedupeKey: 'speech-status'
          })
        }
      }
      recognition.onerror = (event) => {
        notify({
          tone: 'error',
          message: describeSpeechRecognitionError(event),
          dedupeKey: 'speech-status'
        })
        setVoiceListening(false)
        setVoiceRecording(false)
      }
      recognition.onend = () => {
        setVoiceListening(false)
        setVoiceRecording(false)
      }
      recognition.start()
      started = true
      setVoiceRecording(true)
      notify({
        tone: 'info',
        message: prepared.local
          ? '正在使用本地语音识别听写'
          : '正在使用系统语音服务听写',
        dedupeKey: 'speech-status'
      })
    } catch (reason) {
      notify({
        tone: 'error',
        message:
          reason instanceof Error
            ? reason.message
            : '无法启动语音识别，请检查系统语音设置',
        dedupeKey: 'speech-status'
      })
    } finally {
      if (!started) {
        setVoiceListening(false)
        setVoiceRecording(false)
      }
    }
  }

  const startVoiceInput = async (): Promise<void> => {
    const speech = window.goodbuddy.speech
    if (!speech) {
      await startWebSpeechInput()
      return
    }
    const audioWindow = window as typeof window & {
      webkitAudioContext?: typeof AudioContext
    }
    const AudioContextType =
      audioWindow.AudioContext ?? audioWindow.webkitAudioContext
    if (!navigator.mediaDevices?.getUserMedia || !AudioContextType) {
      notify({
        tone: 'error',
        message: '当前系统无法访问麦克风，请检查系统录音设备和权限',
        dedupeKey: 'speech-status'
      })
      return
    }
    setVoiceListening(true)
    setVoiceRecording(false)
    voiceStartingRef.current = true
    try {
      const recording = await startPcmRecording(
        navigator.mediaDevices,
        AudioContextType
      )
      voiceStartingRef.current = false
      if (voiceDisposedRef.current) {
        void recording.result.catch(() => undefined)
        recording.cancel()
        return
      }
      voiceRecordingRef.current = recording
      setVoiceRecording(true)
      notify({
        tone: 'info',
        message: '正在录音，再次点击语音按钮即可结束并识别',
        dedupeKey: 'speech-status'
      })
      void recording.result
        .then(async ({ audio, sampleRate }) => {
          voiceRecordingRef.current = undefined
          setVoiceRecording(false)
          const requestId = crypto.randomUUID()
          voiceRequestIdRef.current = requestId
          notify({
            tone: 'info',
            message: '正在使用本地语音模型识别',
            dedupeKey: 'speech-status'
          })
          const result = await speech.transcribe({
            requestId,
            sampleRate,
            audio
          })
          if (voiceRequestIdRef.current !== requestId) {
            return
          }
          const transcript = result.text.trim()
          if (!transcript) {
            notify({
              tone: 'info',
              message: '没有识别到语音，请靠近麦克风后重试',
              dedupeKey: 'speech-status'
            })
            return
          }
          setInput((current) =>
            current ? `${current} ${transcript}` : transcript
          )
          notify({
            tone: 'success',
            message: '语音已转为文字，可编辑后发送',
            dedupeKey: 'speech-status'
          })
        })
        .catch((reason: unknown) => {
          notify({
            tone:
              reason instanceof Error &&
              reason.name === 'AbortError'
                ? 'info'
                : 'error',
            message:
              reason instanceof Error &&
              reason.name === 'AbortError'
                ? '语音识别已取消'
                : reason instanceof Error
                  ? reason.message
                  : '本地语音识别失败',
            dedupeKey: 'speech-status'
          })
        })
        .finally(() => {
          voiceRequestIdRef.current = undefined
          setVoiceListening(false)
          setVoiceRecording(false)
        })
    } catch (reason) {
      voiceStartingRef.current = false
      setVoiceListening(false)
      setVoiceRecording(false)
      notify({
        tone: 'error',
        message:
          reason instanceof Error &&
          reason.name === 'NotAllowedError'
            ? '麦克风权限被拒绝，请在系统隐私设置中允许 GoodBuddy 使用麦克风'
            : reason instanceof Error
              ? reason.message
              : '无法开始录音',
        dedupeKey: 'speech-status'
      })
    }
  }

  const toggleVoiceInput = (): void => {
    if (voiceStartingRef.current) {
      return
    }
    const recording = voiceRecordingRef.current
    if (recording) {
      setVoiceRecording(false)
      recording.stop()
      notify({
        tone: 'info',
        message: '录音完成，正在准备本地识别',
        dedupeKey: 'speech-status'
      })
      return
    }
    const requestId = voiceRequestIdRef.current
    if (requestId) {
      voiceRequestIdRef.current = undefined
      void window.goodbuddy.speech?.cancel(requestId)
      notify({
        tone: 'info',
        message: '语音识别已取消',
        dedupeKey: 'speech-status'
      })
      setVoiceListening(false)
      setVoiceRecording(false)
      return
    }
    void startVoiceInput()
  }

  const refreshSelectedKnowledge = async (): Promise<void> => {
    await refreshKnowledge(knowledgeSnapshot.selectedLibraryId)
  }

  const createKnowledgeLibrary = async (
    input: Parameters<
      typeof window.goodbuddy.knowledge.createLibrary
    >[0]
  ): Promise<void> => {
    const library = await window.goodbuddy.knowledge.createLibrary(input)
    setEnabledKnowledgeLibraryIds((current) => [...current, library.id])
    await refreshKnowledge(library.id)
  }

  const deleteKnowledgeLibrary = async (libraryId: string): Promise<void> => {
    await window.goodbuddy.knowledge.deleteLibrary(libraryId)
    await refreshKnowledge()
  }

  const runKnowledgeSourceAction = async (
    action: () => Promise<void>
  ): Promise<void> => {
    await action()
    await refreshSelectedKnowledge()
  }

  const openActivityConversation = (conversationId: string): void => {
    const conversation = conversations.find(
      (candidate) => candidate.id === conversationId
    )
    if (!conversation) {
      notify({ tone: 'info', message: '对应对话已被删除' })
      return
    }
    if (conversation.projectId) {
      const project = projects.find(
        (candidate) => candidate.id === conversation.projectId
      )
      setActiveProjectId(conversation.projectId)
      if (project) {
        setWorkMode(
          normalizeInteractiveWorkMode(project.defaultWorkMode)
        )
      }
    }
    setActiveId(conversationId)
    setUnreadConversationIds((current) => {
      if (!current.has(conversationId)) {
        return current
      }
      const next = new Set(current)
      next.delete(conversationId)
      return next
    })
    setView('chat')
  }

  const clearLocalData = async (): Promise<void> => {
    for (const requestId of activeRuns.current.keys()) {
      await window.goodbuddy.agent.cancel(requestId)
    }
    activeRuns.current.clear()
    for (const attachment of attachments) {
      await window.goodbuddy.context.remove(attachment.id)
    }
    for (const library of knowledgeSnapshot.libraries) {
      await window.goodbuddy.knowledge.deleteLibrary(library.id)
    }
    await window.goodbuddy.app.clearLocalData()
    const conversation = createConversation(
      activeProjectId || undefined,
      runtimeSettings
        ? getDefaultRuntimeSelection(runtimeSettings)
        : undefined
    )
    setConversations([conversation])
    setActiveId(conversation.id)
    setActivityRecords([])
    setAssistantTasks([])
    setTokenUsage(emptyTokenUsage)
    setAssistantArtifacts([])
    setAssistantMemories([])
    setAssistantSchedules([])
    setAssistantHeartbeats([])
    setHeartbeatEntries([])
    setHeartbeatRuns([])
    setKnowledgeSnapshot({
      libraries: [],
      sources: [],
      documents: [],
      graphNodes: [],
      graphRelations: [],
      evidence: []
    })
    setEnabledKnowledgeLibraryIds([])
    updateAttachments([])
    setInput('')
    setView('chat')
    notify({
      tone: 'success',
      message: '本地对话、任务、记忆、心跳、自动化和知识库索引已清除'
    })
  }

  const isRunning =
    activeConversation?.messages.some(
      (message) => message.state === 'streaming'
    ) ?? false

  return (
    <div className="app-shell">
      <aside
        aria-label={narrowWindow && sidebarOpen ? '主侧栏' : undefined}
        aria-hidden={!sidebarOpen}
        aria-modal={narrowWindow && sidebarOpen ? 'true' : undefined}
        className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}
        inert={!sidebarOpen}
        ref={sidebarRef}
        role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
      >
        <div className="brand">
          <div className="brand__mark">
            <img
              alt=""
              aria-hidden="true"
              src={
                resolvedAppearanceTheme === 'dark'
                  ? goodbuddyDarkIcon
                  : goodbuddyLightIcon
              }
            />
          </div>
          <div className="brand__copy">
            <strong>GoodBuddy</strong>
            <span>AI desktop companion</span>
          </div>
        </div>

        <ProjectSwitcher
          activeProjectId={activeProjectId}
          onArchive={archiveProject}
          onCreate={createProject}
          onDelete={deleteProject}
          onSelect={selectProject}
          onSelectRoot={() =>
            window.goodbuddy.settings.selectWorkspace()
          }
          onUpdate={updateProject}
          projects={projects}
        />

        {activeProject?.kind !== 'channel' && (
          <button
            className="new-chat"
            onClick={newConversation}
            type="button"
          >
            <MessageSquarePlus size={17} />
            <span>新建对话</span>
            <kbd>Ctrl N</kbd>
          </button>
        )}

        <div className="sidebar-search">
          <Search size={15} />
          <input
            aria-label="搜索对话"
            onChange={(event) => setSearchQuery(event.target.value)}
            placeholder="搜索标题或消息"
            value={searchQuery}
          />
        </div>

        <nav className="primary-nav" aria-label="主导航">
          <button
            aria-current={view === 'chat' ? 'page' : undefined}
            className={
              view === 'chat' ? 'nav-item nav-item--active' : 'nav-item'
            }
            onClick={() => setView('chat')}
            type="button"
          >
            <MessageSquare aria-hidden="true" size={17} />
            <span>对话</span>
          </button>
          {magicNotesEnabled && (
            <button
              aria-current={view === 'magic-notes' ? 'page' : undefined}
              className={
                view === 'magic-notes'
                  ? 'nav-item nav-item--active'
                  : 'nav-item'
              }
              onClick={() => setView('magic-notes')}
              type="button"
            >
              <Sparkles aria-hidden="true" size={17} />
              <span>魔法笔记</span>
            </button>
          )}
          <button
            aria-current={view === 'knowledge' ? 'page' : undefined}
            className={
              view === 'knowledge'
                ? 'nav-item nav-item--active'
                : 'nav-item'
            }
            onClick={() => setView('knowledge')}
            type="button"
          >
            <Library aria-hidden="true" size={17} />
            <span>知识库</span>
          </button>
          <button
            aria-current={view === 'heartbeat' ? 'page' : undefined}
            className={
              view === 'heartbeat'
                ? 'nav-item nav-item--active'
                : 'nav-item'
            }
            onClick={() => setView('heartbeat')}
            type="button"
          >
            <HeartPulse aria-hidden="true" size={17} />
            <span>智能心跳</span>
            {pendingHeartbeatSuggestionCount > 0 && (
              <span
                aria-label={`${pendingHeartbeatSuggestionCount} 条待处理建议`}
                className="nav-item__badge"
              >
                {pendingHeartbeatSuggestionCount}
              </span>
            )}
          </button>
          <button
            aria-current={view === 'activity' ? 'page' : undefined}
            className={
              view === 'activity'
                ? 'nav-item nav-item--active'
                : 'nav-item'
            }
            onClick={() => setView('activity')}
            type="button"
          >
            <TerminalSquare aria-hidden="true" size={17} />
            <span>任务与活动</span>
          </button>
        </nav>

        <div className="conversation-list">
          <p className="section-label">最近会话</p>
          {filteredConversations.map((conversation) => (
            <div className="conversation-entry" key={conversation.id}>
              <div
                className={
                  conversation.id === activeId
                    ? 'conversation-row conversation-row--active'
                    : 'conversation-row'
                }
              >
                <button
                  className={
                    conversation.id === activeId
                      ? 'conversation-item conversation-item--active'
                      : 'conversation-item'
                  }
                  type="button"
                  onClick={() => {
                    setConversationActionsId('')
                    setActiveId(conversation.id)
                    setUnreadConversationIds((current) => {
                      if (!current.has(conversation.id)) {
                        return current
                      }
                      const next = new Set(current)
                      next.delete(conversation.id)
                      return next
                    })
                    setView('chat')
                  }}
                >
                  <span>
                    {conversation.remote && (
                      <b className="conversation-source-badge">
                        {
                          projectChannelLabels[
                            conversation.remote.channel
                          ]
                        }
                      </b>
                    )}
                    {conversation.title}
                    {unreadConversationIds.has(conversation.id) && (
                      <i
                        aria-label="未读"
                        className="conversation-unread"
                        title="未读远程消息"
                      />
                    )}
                  </span>
                  <small>{formatTime(conversation.updatedAt)}</small>
                </button>
                <button
                  aria-controls={`conversation-actions-${conversation.id}`}
                  aria-expanded={
                    conversationActionsId === conversation.id
                  }
                  aria-label={`更多会话操作 ${conversation.title}`}
                  className="conversation-more"
                  onClick={() => {
                    setRenamingConversationId('')
                    setConfirmingConversationId('')
                    setConversationActionsId((current) =>
                      current === conversation.id ? '' : conversation.id
                    )
                  }}
                  ref={(element) => {
                    if (element) {
                      conversationActionTriggerRefs.current.set(
                        conversation.id,
                        element
                      )
                    } else {
                      conversationActionTriggerRefs.current.delete(
                        conversation.id
                      )
                    }
                  }}
                  type="button"
                >
                  <MoreHorizontal size={14} />
                </button>
              </div>
              {conversationActionsId === conversation.id && (
                <div
                  aria-label={`${conversation.title} 的会话操作`}
                  className="conversation-actions"
                  id={`conversation-actions-${conversation.id}`}
                >
                  {!conversation.remote && (
                    <button
                      onClick={() => {
                        setConversationActionsId('')
                        setRenamingConversationId(conversation.id)
                      }}
                      type="button"
                    >
                      <Edit3 size={14} />
                      重命名会话
                    </button>
                  )}
                  <button
                    onClick={() => {
                      setConversationActionsId('')
                      void copyConversation(conversation).finally(() =>
                        focusConversationActions(conversation.id)
                      )
                    }}
                    type="button"
                  >
                    <Copy size={14} />
                    复制完整会话
                  </button>
                  <button
                    onClick={() => {
                      setConversationActionsId('')
                      exportConversation(conversation)
                      focusConversationActions(conversation.id)
                    }}
                    type="button"
                  >
                    <Download size={14} />
                    导出 Markdown
                  </button>
                  {!conversation.remote && (
                    <DestructiveConfirmActions
                      cancelAriaLabel={`取消删除对话 ${conversation.title}`}
                      confirmAriaLabel={`确认永久删除对话 ${conversation.title}`}
                      confirmLabel="永久删除对话"
                      confirming={
                        confirmingConversationId === conversation.id
                      }
                      disabled={
                        deletingConversationId === conversation.id
                      }
                      icon={<Trash2 aria-hidden="true" size={14} />}
                      message="将永久删除此会话的全部内容；如果此会话有正在运行的任务，也会同时停止。此操作不可恢复。"
                      onCancel={() => setConfirmingConversationId('')}
                      onConfirm={() =>
                        void deleteConversation(conversation.id)
                      }
                      onRequestConfirm={() =>
                        setConfirmingConversationId(conversation.id)
                      }
                      triggerAriaLabel={`删除对话 ${conversation.title}`}
                      triggerLabel="删除对话"
                    />
                  )}
                </div>
              )}
              {!conversation.remote &&
                renamingConversationId === conversation.id && (
                <form
                  className="conversation-rename"
                  onSubmit={(event) => {
                    event.preventDefault()
                    const input =
                      event.currentTarget.elements.namedItem('title')
                    if (input instanceof HTMLInputElement) {
                      saveTitle(conversation.id, input.value)
                    }
                  }}
                >
                  <input
                    aria-label={`重命名会话 ${conversation.title}`}
                    autoFocus
                    defaultValue={conversation.title}
                    maxLength={80}
                    name="title"
                    onKeyDown={(event) => {
                      if (event.key === 'Escape') {
                        setRenamingConversationId('')
                        focusConversationActions(conversation.id)
                      }
                    }}
                    pattern=".*\S.*"
                    required
                  />
                  <button
                    aria-label="保存会话名称"
                    type="submit"
                  >
                    <Check size={14} />
                  </button>
                  <button
                    aria-label="取消重命名"
                    onClick={() => {
                      setRenamingConversationId('')
                      focusConversationActions(conversation.id)
                    }}
                    type="button"
                  >
                    <X size={14} />
                  </button>
                </form>
                )}
            </div>
          ))}
          {filteredConversations.length === 0 && (
            <p className="conversation-empty">
              {activeProject?.kind === 'channel' &&
              !searchQuery.trim()
                ? '尚无远程会话'
                : '没有匹配的对话'}
            </p>
          )}
        </div>

        <div className="sidebar-footer">
          <button
            className="user-card"
            type="button"
            onClick={() => setView('settings')}
          >
            <span className="avatar">GB</span>
            <span className="user-card__copy">
              <strong>本地工作区</strong>
              <small>{appInfo ? `${appInfo.platform} · ${appInfo.arch}` : '加载中'}</small>
            </span>
            <Settings size={16} />
          </button>
        </div>
      </aside>
      {sidebarOpen && (
        <button
          aria-label="关闭侧栏"
          className="sidebar-backdrop"
          onClick={closeNarrowSidebar}
          type="button"
        />
      )}

      <main
        aria-hidden={narrowWindow && sidebarOpen ? 'true' : undefined}
        className="workspace"
        inert={narrowWindow && sidebarOpen}
      >
        <header className="topbar">
          <button
            className="icon-button sidebar-toggle"
            type="button"
            aria-label="切换侧栏"
            onClick={() => setSidebarOpen((open) => !open)}
            ref={sidebarToggleRef}
          >
            <PanelLeft aria-hidden="true" size={18} />
          </button>
          {view === 'chat' && (
            <>
              <div
                className="conversation-title"
                title={activeConversation?.title}
              >
                <span>
                  {activeConversation?.title ??
                    (activeProject?.kind === 'channel'
                      ? '远程会话'
                      : '新对话')}
                </span>
                {activeConversation?.remote && (
                  <b className="conversation-source-badge">
                    {
                      projectChannelLabels[
                        activeConversation.remote.channel
                      ]
                    }
                  </b>
                )}
              </div>
              <ScopeBadge
                scope={
                  activeProject
                    ? {
                        kind: 'project',
                        projectName: activeProject.name
                      }
                    : {
                        kind: 'unavailable',
                        explanation: '当前项目尚未加载。'
                      }
                }
              />
            </>
          )}
          <div className="topbar__actions">
            <span
              className={
                runtime?.available
                  ? 'runtime-status runtime-status--online'
                  : 'runtime-status'
              }
              title={runtime?.detail}
            >
              <span className="runtime-status__dot" />
              <span className="runtime-status__label">
                {runtime?.label ?? '正在检测运行时'}
              </span>
              {runtime?.capability === 'image-generation' && (
                <span className="runtime-capability-badge">生图</span>
              )}
            </span>
            {view === 'chat' && (
              <button
                aria-label="切换助手工作栏"
                aria-pressed={assistantSidebarOpen}
                className={
                  assistantSidebarOpen
                    ? 'icon-button icon-button--active'
                    : 'icon-button'
                }
                onClick={() =>
                  setAssistantSidebarOpen((current) => !current)
                }
                type="button"
              >
                <PanelRightOpen size={18} />
              </button>
            )}
            <div className="topbar-menu" ref={topbarMenuRef}>
              <button
                aria-expanded={topbarMenuOpen}
                aria-haspopup="menu"
                aria-label="应用菜单"
                className="icon-button"
                onClick={() =>
                  setTopbarMenuOpen((current) => !current)
                }
                ref={topbarMenuTriggerRef}
                type="button"
              >
                <MoreHorizontal size={18} />
              </button>
              {topbarMenuOpen && (
                <div
                  aria-label="应用操作"
                  className="topbar-menu__popover"
                  role="menu"
                >
                  <button
                    onClick={() => {
                      setTopbarMenuOpen(false)
                      setView('settings')
                    }}
                    role="menuitem"
                    type="button"
                  >
                    <ShieldCheck size={16} />
                    安全与 Runtime 设置
                  </button>
                  <button
                    onClick={() => {
                      setTopbarMenuOpen(false)
                      notify({
                        tone: 'info',
                        message:
                          '输入问题后按 Enter 发送，Shift+Enter 换行。附件只会在你明确选择后发送。'
                      })
                    }}
                    role="menuitem"
                    type="button"
                  >
                    <CircleHelp size={16} />
                    使用帮助
                  </button>
                </div>
              )}
            </div>
          </div>
          <WindowControls
            onError={handleWindowControlError}
          />
        </header>

        {view === 'chat' ? (
          <PageShell variant="reading">
            <section className="chat" ref={scrollRef}>
          {activeProject?.kind === 'channel' &&
            !activeConversation && (
              <EmptyState
                action={
                  <button
                    className="secondary-button"
                    onClick={() => setView('settings')}
                    type="button"
                  >
                    打开设置
                  </button>
                }
                description={`请先连接${activeProject.name}，远程用户发送第一条消息后，会话会自动出现在这里。`}
                icon={<MessageSquare size={28} />}
                level="page"
                title="尚无远程会话"
              />
            )}
          {activeConversation && isUnusedConversation(activeConversation) && (
            <div className="welcome">
              <div className="welcome__badge">
                <Sparkles size={18} />
              </div>
              <p className="eyebrow">GOODBUDDY WORKSPACE</p>
              <h1>今天想一起完成什么？</h1>
              <p className="welcome__description">
                快速提问、梳理信息，或连接 OpenCode 使用文件搜索和开发工具。
              </p>
              <div className="quick-actions">
                {quickActions.map((action) => (
                  <button
                    key={action.title}
                    type="button"
                    onClick={() => {
                      setInput(action.prompt)
                      inputRef.current?.focus()
                    }}
                  >
                    <span className="quick-actions__icon">
                      <FileText size={17} />
                    </span>
                    <strong>{action.title}</strong>
                    <small>{action.description}</small>
                  </button>
                ))}
              </div>
            </div>
          )}

          <div className="message-list">
            {activeConversation?.messages.map((message, messageIndex) => (
              <article
                className={`message message--${message.role}`}
                key={message.id}
              >
                <div className="message__avatar">
                  {message.role === 'assistant' ? (
                    <Bot size={18} />
                  ) : (
                    <UserRound size={18} />
                  )}
                </div>
                <div className="message__body">
                  <div className="message__meta">
                    <strong>
                      {message.role === 'assistant'
                        ? 'GoodBuddy'
                        : '用户'}
                    </strong>
                    <span>{formatTime(message.createdAt)}</span>
                  </div>
                  {message.attachments &&
                    message.attachments.length > 0 && (
                      <div
                        aria-label="消息附件"
                        className="message-attachments"
                      >
                        {message.attachments.map((attachment) => {
                          const imageSource =
                            attachment.kind === 'image'
                              ? attachment.contentUrl ??
                                attachment.thumbnailUrl
                              : undefined
                          const imageItem = imageSource
                            ? {
                                src: imageSource,
                                title: attachment.name
                              }
                            : undefined
                          return (
                            <div
                              className={`message-attachment message-attachment--${attachment.kind}`}
                              key={attachment.id}
                              title={attachment.preview}
                            >
                              {imageItem ? (
                                <button
                                  aria-label={`查看图片 ${attachment.name}`}
                                  className="message-image-button"
                                  onClick={(event) =>
                                    openImageViewer(
                                      imageItem,
                                      event.currentTarget
                                    )
                                  }
                                  type="button"
                                >
                                  <img
                                    alt={attachment.name}
                                    loading="lazy"
                                    src={imageSource}
                                  />
                                </button>
                              ) : (
                                <span
                                  aria-hidden="true"
                                  className="message-attachment__icon"
                                >
                                  <FileText size={16} />
                                </span>
                              )}
                              <span className="message-attachment__details">
                                <strong>{attachment.name}</strong>
                                <small>
                                  {formatAttachmentSize(attachment.size)}
                                </small>
                                {imageItem && (
                                  <span className="message-image-actions">
                                    <button
                                      onClick={(event) =>
                                        openImageViewer(
                                          imageItem,
                                          event.currentTarget
                                        )
                                      }
                                      type="button"
                                    >
                                      查看
                                    </button>
                                    <button
                                      aria-label={`下载图片 ${attachment.name}`}
                                      onClick={() =>
                                        downloadImage(imageItem)
                                      }
                                      type="button"
                                    >
                                      <Download size={12} />
                                      下载
                                    </button>
                                  </span>
                                )}
                              </span>
                            </div>
                          )
                        })}
                      </div>
                    )}
                  {message.blocks && message.blocks.length > 0 ? (
                    <div className="message-blocks">
                      {groupMessageBlocks(message.blocks).map((item) =>
                        item.kind === 'tools' ? (
                          <ToolExecutionList
                            key={item.id}
                            tools={item.tools}
                          />
                        ) : item.block.type === 'reasoning' ? (
                          <details
                            className="message-reasoning"
                            key={item.block.id}
                            open={
                              message.state === 'streaming' &&
                              message.blocks?.at(-1)?.id ===
                                item.block.id
                            }
                          >
                            <summary>
                              {message.state === 'streaming'
                                ? '正在推理'
                                : '推理过程'}
                            </summary>
                            <div className="markdown-content message-reasoning__content">
                              <MarkdownRenderer>
                                {item.block.content}
                              </MarkdownRenderer>
                            </div>
                          </details>
                        ) : (
                          <div
                            className="markdown-content message__content"
                            key={item.block.id}
                          >
                            <MarkdownRenderer>
                              {item.block.content}
                            </MarkdownRenderer>
                          </div>
                        )
                      )}
                    </div>
                  ) : (
                    <>
                      {message.reasoning && (
                        <details
                          className="message-reasoning"
                          key={`${message.id}-${message.state}`}
                          open={message.state === 'streaming'}
                        >
                          <summary>
                            {message.state === 'streaming'
                              ? '正在推理'
                              : '推理过程'}
                          </summary>
                          <div className="markdown-content message-reasoning__content">
                            <MarkdownRenderer>
                              {message.reasoning}
                            </MarkdownRenderer>
                          </div>
                        </details>
                      )}
                      {message.content && (
                        <div className="markdown-content message__content">
                          <MarkdownRenderer>
                            {message.content}
                          </MarkdownRenderer>
                        </div>
                      )}
                    </>
                  )}
                  {message.artifactIds?.map((artifactId) => {
                    const candidate =
                      assistantArtifactById.get(artifactId)
                    const artifact =
                      candidate?.kind === 'image' &&
                      candidate.content &&
                      /^data:image\/(?:png|jpeg|webp);base64,/u.test(
                        candidate.content
                      )
                        ? candidate
                        : undefined
                    return artifact?.content ? (
                      <figure
                        className="message-generated-image"
                        key={artifact.id}
                      >
                        <button
                          aria-label={`查看图片 ${artifact.title}`}
                          className="message-image-button"
                          onClick={(event) =>
                            openImageViewer(
                              {
                                src: artifact.content!,
                                title: artifact.title
                              },
                              event.currentTarget
                            )
                          }
                          type="button"
                        >
                          <img
                            alt={artifact.title}
                            loading="lazy"
                            src={artifact.content}
                          />
                        </button>
                        <figcaption>{artifact.title}</figcaption>
                        <div className="message-image-actions">
                          <button
                            onClick={(event) =>
                              openImageViewer(
                                {
                                  src: artifact.content!,
                                  title: artifact.title
                                },
                                event.currentTarget
                              )
                            }
                            type="button"
                          >
                            查看
                          </button>
                          <button
                            aria-label={`下载图片 ${artifact.title}`}
                            onClick={() =>
                              downloadImage({
                                src: artifact.content!,
                                title: artifact.title
                              })
                            }
                            type="button"
                          >
                            <Download size={12} />
                            下载
                          </button>
                        </div>
                      </figure>
                    ) : null
                  })}
                  {message.sources && message.sources.length > 0 && (
                    <div className="message-sources">
                      <Library size={14} />
                      <span>
                        来源：{[...new Set(message.sources)].join('、')}
                      </span>
                    </div>
                  )}
                  {message.sourceReferences &&
                    message.sourceReferences.length > 0 && (
                      <details className="message-citations">
                        <summary>
                          查看 {message.sourceReferences.length} 条证据引用
                        </summary>
                        <ol>
                          {message.sourceReferences.map(
                            (reference, referenceIndex) => (
                              <li
                                key={`${reference.documentId}:${reference.locator ?? referenceIndex}`}
                              >
                                <strong>
                                  [{referenceIndex + 1}]{' '}
                                  {reference.documentName}
                                </strong>
                                {reference.locator && (
                                  <small>{reference.locator}</small>
                                )}
                                <p>{reference.snippet}</p>
                                {reference.retrievalChannels && (
                                  <small>
                                    检索：
                                    {reference.retrievalChannels
                                      .map((channel) =>
                                        channel === 'fts'
                                          ? '全文'
                                          : channel === 'vector'
                                            ? '向量'
                                            : '图谱'
                                      )
                                      .join(' + ')}
                                  </small>
                                )}
                              </li>
                            )
                          )}
                        </ol>
                      </details>
                    )}
                  {(!message.blocks || message.blocks.length === 0) &&
                    message.tools &&
                    message.tools.length > 0 && (
                      <ToolExecutionList tools={message.tools} />
                    )}
                  {message.subagents && message.subagents.length > 0 && (
                    <section
                      aria-label="子专家状态"
                      className="subagent-status-list"
                    >
                      {message.subagents.slice(0, 3).map((subagent) => (
                        <article
                          className={`subagent-status-card subagent-status-card--${subagent.state}`}
                          key={subagent.childTaskId}
                        >
                          <Bot aria-hidden="true" size={15} />
                          <div>
                            <strong>{subagent.expertName}</strong>
                            <small>
                              {subagent.routingMode === 'smart'
                                ? '智能路由'
                                : '手动指定'}
                            </small>
                            {(subagent.error || subagent.reason) &&
                              (subagent.state === 'failed' ||
                                subagent.state === 'cancelled') && (
                                <p>
                                  {subagent.error ?? subagent.reason}
                                </p>
                              )}
                          </div>
                          <span>
                            {subagentStateLabels[subagent.state]}
                          </span>
                        </article>
                      ))}
                    </section>
                  )}
                  {message.approval && (
                    <div className="approval-card">
                      <ShieldCheck size={18} />
                      <div>
                        <strong>{message.approval.title}</strong>
                        <p>{message.approval.description}</p>
                        {message.approval.argumentSummary && (
                          <code>{message.approval.argumentSummary}</code>
                        )}
                      </div>
                      <button
                        className="approval-card__deny"
                        onClick={() =>
                          void respondToApproval(
                            activeConversation.id,
                            message.id,
                            message.approval!.id,
                            'deny'
                          )
                        }
                        type="button"
                      >
                        拒绝
                      </button>
                      <button
                        className="approval-card__allow"
                        onClick={() =>
                          void respondToApproval(
                            activeConversation.id,
                            message.id,
                            message.approval!.id,
                            'once'
                          )
                        }
                        type="button"
                      >
                        仅此次
                      </button>
                      <button
                        className="approval-card__allow"
                        onClick={() =>
                          void respondToApproval(
                            activeConversation.id,
                            message.id,
                            message.approval!.id,
                            'session'
                          )
                        }
                        type="button"
                      >
                        此会话
                      </button>
                      {message.approval.allowPermanent && (
                        <button
                          className="approval-card__allow"
                          onClick={() =>
                            void respondToApproval(
                              activeConversation.id,
                              message.id,
                              message.approval!.id,
                              'permanent'
                            )
                          }
                          type="button"
                        >
                          永久允许
                        </button>
                      )}
                    </div>
                  )}
                  {message.question && (
                    <AgentQuestionCard
                      key={message.question.questionId}
                      onReject={() =>
                        respondToQuestion(
                          activeConversation.id,
                          message.id,
                          message.question!.questionId
                        )
                      }
                      onSubmit={(answers) =>
                        respondToQuestion(
                          activeConversation.id,
                          message.id,
                          message.question!.questionId,
                          answers
                        )
                      }
                      value={message.question}
                    />
                  )}
                  {message.status && (
                    <div
                      className={
                        message.state === 'error'
                          ? 'message__status message__status--error'
                          : 'message__status'
                      }
                    >
                      <span
                        aria-hidden="true"
                        className={
                          message.state === 'streaming'
                            ? 'message__status-dot message__status-dot--active'
                            : 'message__status-dot'
                        }
                      />
                      {message.status}
                    </div>
                  )}
                  {message.state === 'error' && (
                    <button
                      className="message-retry"
                      onClick={() => {
                        const previous =
                          activeConversation.messages[messageIndex - 1]
                        if (previous?.role === 'user') {
                          setInput(previous.content)
                          inputRef.current?.focus()
                        }
                      }}
                      type="button"
                    >
                      重新编辑并发送
                    </button>
                  )}
                </div>
              </article>
            ))}
          </div>
            </section>

            <footer className="composer-wrap">
          {activeProject?.kind === 'channel' ? (
            <div className="remote-conversation-notice">
              <MessageSquare aria-hidden="true" size={18} />
              <div>
                <strong>远程通道会话</strong>
                <span>
                  {activeConversation?.remote
                    ? `请在 ${projectChannelLabels[activeConversation.remote.channel]} 客户端继续发送消息。本窗口用于查看历史、任务与执行结果。`
                    : '远程用户发送消息后，会话会自动出现在这里。'}
                </span>
              </div>
            </div>
          ) : (
          <>
          <div className="composer">
            {attachments.length > 0 && (
              <div className="context-list">
                {attachments.map((attachment) => (
                  <div
                    className="context-chip"
                    key={attachment.id}
                    title={attachment.preview}
                  >
                    {attachment.kind === 'image' &&
                    attachment.thumbnailUrl ? (
                      <img
                        alt=""
                        className="context-chip__thumbnail"
                        src={attachment.thumbnailUrl}
                      />
                    ) : (
                      <FileText size={14} />
                    )}
                    <span>
                      <strong>{attachment.name}</strong>
                      <small>
                        {formatAttachmentSize(attachment.size)}
                      </small>
                    </span>
                    <button
                      aria-label={`移除 ${attachment.name}`}
                      onClick={() => {
                        void window.goodbuddy.context.remove(attachment.id)
                        updateAttachments((current) =>
                          current.filter(
                            (item) => item.id !== attachment.id
                          )
                        )
                      }}
                      type="button"
                    >
                      ×
                    </button>
                  </div>
                ))}
              </div>
            )}
            <div className="composer__input">
              <textarea
                aria-label="向 GoodBuddy 提问"
                placeholder={
                  runtime?.capability === 'image-generation'
                    ? '描述你想生成的图片…'
                    : '给 GoodBuddy 发消息…'
                }
                ref={inputRef}
                rows={3}
                value={input}
                onChange={(event) => setInput(event.target.value)}
                onInput={(event) =>
                  resizeComposerTextarea(event.currentTarget)
                }
                onKeyDown={(event) => {
                  if (event.key === 'Enter' && !event.shiftKey) {
                    event.preventDefault()
                    void submit()
                  }
                }}
              />
            </div>
            <div className="composer__toolbar">
              <div className="composer__controls">
                <div
                  aria-label="添加内容"
                  className="composer__tool-group"
                  role="group"
                >
                  <button
                    type="button"
                    aria-label="添加附件"
                    onClick={() =>
                      void addContext(() =>
                        window.goodbuddy.context.selectFiles()
                      )
                    }
                    title="添加附件"
                  >
                    <Paperclip aria-hidden="true" size={18} />
                  </button>
                  <button
                    aria-label="读取剪贴板"
                    onClick={() =>
                      void addContext(() =>
                        window.goodbuddy.context.readClipboard()
                      )
                    }
                    title="添加剪贴板文本或图片"
                    type="button"
                  >
                    <ClipboardPaste aria-hidden="true" size={18} />
                  </button>
                  <button
                    aria-label="截取当前屏幕"
                    onClick={() =>
                      void addContext(() =>
                        window.goodbuddy.context.captureScreen()
                      )
                    }
                    title="截取当前屏幕"
                    type="button"
                  >
                    <MonitorUp aria-hidden="true" size={18} />
                  </button>
                  <button
                    aria-label={
                      voiceRecording
                        ? '停止录音'
                        : voiceListening
                          ? '取消语音识别'
                          : '语音输入'
                    }
                    aria-pressed={voiceRecording}
                    className={
                      voiceRecording
                        ? 'composer__voice-button composer__voice-button--recording'
                        : voiceListening
                          ? 'composer__voice-button composer__voice-button--processing'
                          : 'composer__voice-button'
                    }
                    data-state={
                      voiceRecording
                        ? 'recording'
                        : voiceListening
                          ? 'processing'
                          : 'idle'
                    }
                    onClick={toggleVoiceInput}
                    title={
                      voiceRecording
                        ? '停止录音并开始识别'
                        : voiceListening
                          ? '取消语音识别'
                          : '语音转文字，转写后可编辑再发送'
                    }
                    type="button"
                  >
                    <Mic aria-hidden="true" size={18} />
                  </button>
                  <button
                    aria-label="捕获应用窗口"
                    disabled={windowCaptureLoading}
                    onClick={() => void openWindowCapture()}
                    title="选择一个应用或浏览器窗口，仅捕获当前画面"
                    type="button"
                  >
                    <PanelsTopLeft aria-hidden="true" size={18} />
                  </button>
                </div>
                {knowledgeSnapshot.libraries.length > 0 && (
                  <div className="knowledge-scope">
                    <button
                      aria-label={`选择知识库，本次已启用 ${enabledKnowledgeLibraryIds.length} 个`}
                      aria-expanded={knowledgeScopeOpen}
                      onClick={() =>
                        setKnowledgeScopeOpen((current) => !current)
                      }
                      title="选择本次对话检索的知识库"
                      type="button"
                    >
                      <Library aria-hidden="true" size={16} />
                      <span>
                        知识库
                        <strong>{enabledKnowledgeLibraryIds.length}</strong>
                      </span>
                    </button>
                    {knowledgeScopeOpen && (
                      <div className="knowledge-scope__popover">
                        <strong>本次对话检索范围</strong>
                        {knowledgeSnapshot.libraries.map((library) => (
                          <label key={library.id}>
                            <input
                              checked={enabledKnowledgeLibraryIds.includes(
                                library.id
                              )}
                              onChange={(event) =>
                                setEnabledKnowledgeLibraryIds((current) =>
                                  event.target.checked
                                    ? [...new Set([...current, library.id])]
                                    : current.filter(
                                        (id) => id !== library.id
                                      )
                                )
                              }
                              type="checkbox"
                            />
                            <span>{library.name}</span>
                            <small>{library.documentCount} 个文档</small>
                          </label>
                        ))}
                      </div>
                    )}
                  </div>
                )}
                <div
                  aria-label="对话设置"
                  className="composer__configuration"
                  role="group"
                >
                  <label
                    className="composer__expert"
                    title="选择参与本次对话的专家角色"
                  >
                    <Bot aria-hidden="true" size={15} />
                    <select
                      aria-label="专家角色"
                      disabled={runtime?.capability === 'image-generation'}
                      onChange={(event) =>
                        setSelectedExpertId(event.target.value)
                      }
                      value={selectedExpertId}
                    >
                      <option value="">通用助手</option>
                      <option value="team">专家团队（并行）</option>
                      {assistantExperts.map((expert) => (
                        <option key={expert.id} value={expert.id}>
                          {expert.name}
                        </option>
                      ))}
                    </select>
                  </label>
                  <label
                    className={`composer__mode composer__mode--${effectiveWorkMode}`}
                    title={`工作模式：${workModeLabels[effectiveWorkMode]}`}
                  >
                    {effectiveWorkMode === 'execute' ? (
                      <ShieldCheck aria-hidden="true" size={15} />
                    ) : (
                      <CircleHelp aria-hidden="true" size={15} />
                    )}
                    <select
                      aria-describedby="work-mode-hint"
                      aria-label="工作模式"
                      onChange={(event) =>
                        setWorkMode(
                          event.target.value as InteractiveWorkMode
                        )
                      }
                      value={effectiveWorkMode}
                    >
                      {interactiveWorkModes.map((value) => (
                        <option
                          disabled={
                            value === 'execute' &&
                            !runtime?.supportsToolExecution
                          }
                          key={value}
                          value={value}
                        >
                          {workModeLabels[value]}
                        </option>
                      ))}
                    </select>
                  </label>
                  <div className="runtime-picker">
                    <button
                      aria-expanded={runtimeMenuOpen}
                      aria-haspopup="menu"
                      className="model-button"
                      disabled={isRunning || runtimeSwitching}
                      onClick={() => setRuntimeMenuOpen(!runtimeMenuOpen)}
                      onKeyDown={(event) => {
                        if (
                          !runtimeMenuOpen &&
                          (event.key === 'ArrowDown' ||
                            event.key === 'Enter' ||
                            event.key === ' ')
                        ) {
                          event.preventDefault()
                          setRuntimeMenuOpen(true)
                        }
                      }}
                      ref={runtimeMenuButtonRef}
                      title={`Runtime 和模型：${activeRuntimeLabel}`}
                      type="button"
                    >
                      <Sparkles aria-hidden="true" size={15} />
                      <span className="model-button__label">
                        {runtimeSwitching
                          ? '切换中…'
                          : activeRuntimeLabel}
                      </span>
                      {runtime?.capability === 'image-generation' && (
                        <span className="runtime-capability-badge">
                          生图
                        </span>
                      )}
                      <ChevronDown aria-hidden="true" size={14} />
                    </button>
                    {runtimeMenuOpen && (
                      <div
                        aria-label="Runtime 和模型"
                        className="runtime-picker__menu"
                        onKeyDown={(event) => {
                          const items = Array.from(
                            event.currentTarget.querySelectorAll<HTMLButtonElement>(
                              '[role="menuitemradio"], [role="menuitem"]'
                            )
                          ).filter((item) => !item.disabled)
                          const currentIndex = items.indexOf(
                            document.activeElement as HTMLButtonElement
                          )
                          let nextIndex: number | undefined
                          if (event.key === 'ArrowDown') {
                            nextIndex = (currentIndex + 1) % items.length
                          } else if (event.key === 'ArrowUp') {
                            nextIndex =
                              (currentIndex - 1 + items.length) % items.length
                          } else if (event.key === 'Home') {
                            nextIndex = 0
                          } else if (event.key === 'End') {
                            nextIndex = items.length - 1
                          } else if (event.key === 'Escape') {
                            event.preventDefault()
                            setRuntimeMenuOpen(false)
                            runtimeMenuButtonRef.current?.focus()
                          }
                          const nextItem =
                            nextIndex === undefined
                              ? undefined
                              : items.at(nextIndex)
                          if (nextItem) {
                            event.preventDefault()
                            items.forEach((item) => {
                              item.tabIndex = item === nextItem ? 0 : -1
                            })
                            nextItem.focus()
                          }
                        }}
                        ref={runtimeMenuRef}
                        role="menu"
                      >
                        <strong
                          role="presentation"
                        >
                          直连模型
                        </strong>
                        {runtimeSettings?.modelProfiles.map((profile) => (
                        <button
                          aria-checked={
                            activeRuntimeSelectionKey ===
                            `model:${profile.id}`
                          }
                          key={profile.id}
                          onClick={() =>
                            void switchRuntime({
                              provider: 'model',
                              profileId: profile.id
                            })
                          }
                          role="menuitemradio"
                          tabIndex={
                            activeRuntimeSelectionKey ===
                            `model:${profile.id}`
                              ? 0
                              : -1
                          }
                          type="button"
                        >
                          <span>
                            {profile.name}
                            {profile.protocol ===
                              'openai-images-generations' && (
                              <span className="runtime-capability-badge">
                                生图
                              </span>
                            )}
                          </span>
                          <small>{profile.modelName}</small>
                        </button>
                        ))}
                        <div
                          className="runtime-picker__divider"
                          role="separator"
                        />
                        <strong
                          role="presentation"
                        >
                          OpenCode Runtime
                        </strong>
                        {openCodeMenuSelection && openCodeMenuSource && (
                        <button
                          aria-checked={
                            activeRuntimeSelectionKey ===
                            agentRuntimeSelectionKey(openCodeMenuSelection)
                          }
                          onClick={() =>
                            void switchRuntime(openCodeMenuSelection)
                          }
                          role="menuitemradio"
                          tabIndex={
                            activeRuntimeSelectionKey ===
                            agentRuntimeSelectionKey(openCodeMenuSelection)
                              ? 0
                              : -1
                          }
                          type="button"
                        >
                          <span>{openCodeMenuSource.label}</span>
                          <small>{openCodeMenuSource.detail}</small>
                        </button>
                        )}
                        <div
                          className="runtime-picker__divider"
                          role="separator"
                        />
                        <strong
                          role="presentation"
                        >
                          Continue Runtime
                        </strong>
                        {continueMenuSelection && continueMenuSource && (
                        <button
                          aria-checked={
                            activeRuntimeSelectionKey ===
                            agentRuntimeSelectionKey(continueMenuSelection)
                          }
                          onClick={() =>
                            void switchRuntime(continueMenuSelection)
                          }
                          role="menuitemradio"
                          tabIndex={
                            activeRuntimeSelectionKey ===
                            agentRuntimeSelectionKey(continueMenuSelection)
                              ? 0
                              : -1
                          }
                          type="button"
                        >
                          <span>{continueMenuSource.label}</span>
                          <small>{continueMenuSource.detail}</small>
                        </button>
                        )}
                        <div
                          className="runtime-picker__divider"
                          role="separator"
                        />
                        <button
                          onClick={() => {
                            setRuntimeMenuOpen(false)
                            setView('settings')
                          }}
                          role="menuitem"
                          tabIndex={-1}
                          type="button"
                        >
                          <span>管理 Runtime 和模型连接</span>
                        </button>
                      </div>
                    )}
                  </div>
                </div>
              </div>
              {isRunning ? (
                <button
                  className="send-button send-button--stop"
                  type="button"
                  aria-label="停止生成"
                  onClick={() => void stop()}
                  title="停止生成"
                >
                  <Square
                    aria-hidden="true"
                    fill="currentColor"
                    size={15}
                  />
                </button>
              ) : (
                <button
                  className="send-button"
                  type="button"
                  aria-label="发送"
                  disabled={
                    !input.trim() ||
                    !runtime?.available ||
                    runtimeSwitching ||
                    runtimeStatusKey !== activeRuntimeSelectionKey
                  }
                  onClick={() => void submit()}
                  title="发送消息"
                >
                  <Send aria-hidden="true" size={17} />
                </button>
              )}
            </div>
          </div>
          <p
            className={
              contextError
                ? 'composer-hint composer-hint--error'
                : 'composer-hint'
            }
            id="work-mode-hint"
          >
            <span>
              {contextError ??
                (!runtime?.available
                  ? '请先配置可用的模型或 Agent Runtime。'
                  : runtime.capability === 'image-generation'
                    ? '图像生成模型：输入画面描述后，生成结果会直接显示并保存到成果。'
                    : agentRuntimeSelected
                      ? effectiveWorkMode === 'ask'
                        ? `${runtime.label} Ask 模式：只允许搜索当前启用的知识库，不会修改文件。`
                        : `${runtime.label} Execute 模式：工具调用不会弹出 GoodBuddy 审批，并会记录到活动。`
                    : effectiveWorkMode === 'ask'
                      ? 'Ask 模式：只读问答，不会调用工具或修改文件。'
                      : 'Execute 模式：已启用工具自动授权，调用仍会记录到活动。')}
            </span>
            {appInfo?.shortcut && (
              <span className="composer-hint__shortcut">
                快捷唤起：<kbd>{appInfo.shortcut}</kbd>
              </span>
            )}
          </p>
          </>
          )}
            </footer>
          </PageShell>
        ) : view === 'magic-notes' && magicNotesEnabled ? (
          <PageShell variant="master-detail">
            <MagicNotesWorkspace onNotify={notify} />
          </PageShell>
        ) : view === 'knowledge' ? (
          <PageShell variant="master-detail">
            <KnowledgeWorkspace
              documents={knowledgeSnapshot.documents}
              evidence={knowledgeSnapshot.evidence}
              graphNodes={knowledgeSnapshot.graphNodes}
              graphRelations={knowledgeSnapshot.graphRelations}
              libraries={knowledgeSnapshot.libraries}
              loadError={knowledgeLoadError}
              loading={knowledgeLoading}
              onCreateLibrary={createKnowledgeLibrary}
              onCreateEntity={async (input) => {
                const libraryId = knowledgeSnapshot.selectedLibraryId
                if (!libraryId) {
                  throw new Error('请先选择知识库')
                }
                await runKnowledgeSourceAction(() =>
                  window.goodbuddy.knowledge.createEntity(
                    libraryId,
                    input
                  )
                )
              }}
              onCreateRelation={async (input) => {
                const libraryId = knowledgeSnapshot.selectedLibraryId
                if (!libraryId) {
                  throw new Error('请先选择知识库')
                }
                await runKnowledgeSourceAction(() =>
                  window.goodbuddy.knowledge.createRelation(
                    libraryId,
                    input
                  )
                )
              }}
              onDeleteEntity={(entityId) =>
                runKnowledgeSourceAction(() =>
                  window.goodbuddy.knowledge.deleteEntity(entityId)
                )
              }
              onDeleteLibrary={deleteKnowledgeLibrary}
              onUpdateLibrary={(libraryId, update) =>
                runKnowledgeSourceAction(async () => {
                  await window.goodbuddy.knowledge.updateLibrary(
                    libraryId,
                    update
                  )
                })
              }
              onDeleteRelation={(relationId) =>
                runKnowledgeSourceAction(() =>
                  window.goodbuddy.knowledge.deleteRelation(relationId)
                )
              }
              onImportDirectory={(libraryId, files, graphStrategy) => {
                void files
                return runKnowledgeSourceAction(() =>
                  window.goodbuddy.knowledge.selectDirectory(
                    libraryId,
                    graphStrategy
                  )
                )
              }}
              onImportFiles={(libraryId, files, graphStrategy) =>
                runKnowledgeSourceAction(() =>
                  window.goodbuddy.knowledge.importDroppedFiles(
                    libraryId,
                    files,
                    graphStrategy
                  )
                )
              }
              onImportUrl={(libraryId, url, graphStrategy) =>
                runKnowledgeSourceAction(() =>
                  window.goodbuddy.knowledge.importUrl(
                    libraryId,
                    url,
                    graphStrategy
                  )
                )
              }
              onMergeEntities={(sourceId, targetId) =>
                runKnowledgeSourceAction(() =>
                  window.goodbuddy.knowledge.mergeEntities(
                    sourceId,
                    targetId
                  )
                )
              }
              onMoveNode={(nodeId, position) => {
                setKnowledgeSnapshot((current) => ({
                  ...current,
                  graphNodes: current.graphNodes.map((node) =>
                    node.id === nodeId
                      ? { ...node, ...position }
                      : node
                  )
                }))
                void window.goodbuddy.knowledge
                  .moveEntity(nodeId, position)
                  .catch(() => void refreshSelectedKnowledge())
              }}
              onOpenEvidence={(evidence) =>
                notify({
                  tone: 'info',
                  message: `${evidence.documentName}${
                    evidence.location ? ` · ${evidence.location}` : ''
                  }：${evidence.excerpt}`.slice(0, 500)
                })
              }
              onPauseSource={(sourceId) =>
                runKnowledgeSourceAction(() =>
                  window.goodbuddy.knowledge.pauseSource(sourceId)
                )
              }
              onRemoveSource={(sourceId) =>
                runKnowledgeSourceAction(() =>
                  window.goodbuddy.knowledge.removeSource(sourceId)
                )
              }
              onRetrySource={(sourceId) =>
                runKnowledgeSourceAction(() =>
                  window.goodbuddy.knowledge.retrySource(sourceId)
                )
              }
              onRetryLoad={retryKnowledgeLoad}
              onSelectLibrary={(libraryId) => {
                void refreshKnowledge(libraryId).catch(() => {
                  // KnowledgeWorkspace renders the recoverable load error.
                })
              }}
              onSyncSource={(sourceId) =>
                runKnowledgeSourceAction(() =>
                  window.goodbuddy.knowledge.syncSource(sourceId)
                )
              }
              onUpdateEntity={(entityId, update) =>
                runKnowledgeSourceAction(() =>
                  window.goodbuddy.knowledge.updateEntity(entityId, update)
                )
              }
              onUpdateRelation={(relationId, input) =>
                runKnowledgeSourceAction(() =>
                  window.goodbuddy.knowledge.updateRelation(
                    relationId,
                    input
                  )
                )
              }
              selectedLibraryId={knowledgeSnapshot.selectedLibraryId}
              sources={knowledgeSnapshot.sources}
            />
          </PageShell>
        ) : view === 'heartbeat' ? (
          <PageShell variant="dashboard">
            <HeartbeatCenter
              configs={assistantHeartbeats}
              currentProjectName={activeProject?.name}
              entries={heartbeatEntries}
              loadError={heartbeatLoadError}
              loading={heartbeatLoading}
              memories={assistantMemories}
              onCreate={createHeartbeat}
              onRefresh={retryHeartbeatLoad}
              onRetryLoad={retryHeartbeatLoad}
              onRemove={removeHeartbeat}
              onRunNow={runHeartbeat}
              onSetMemoryStatus={setMemoryStatus}
              onSetPaused={setHeartbeatPaused}
              onSetTaskStatus={setHeartbeatTaskStatus}
              onUseFollowUpTask={useHeartbeatTask}
              runs={heartbeatRuns}
              tasks={assistantTasks}
            />
          </PageShell>
        ) : view === 'settings' ? (
          <SettingsPanel
            appearanceTheme={appearanceTheme}
            heartbeats={assistantHeartbeats}
            onAppearanceThemeChange={setAppearanceTheme}
            onClearLocalData={clearLocalData}
            onClose={() => setView('chat')}
            onCreateHeartbeat={createHeartbeat}
            onExpertsChanged={(experts) => {
              setAssistantExperts(experts)
              if (
                (selectedExpertId === 'team' && experts.length < 2) ||
                (selectedExpertId &&
                  selectedExpertId !== 'team' &&
                  !experts.some(
                    (expert) => expert.id === selectedExpertId
                  ))
              ) {
                setSelectedExpertId('')
              }
            }}
            onMagicNotesEnabledChange={(enabled) => {
              setMagicNotesEnabled(enabled)
            }}
            onRemoveHeartbeat={removeHeartbeat}
            onRunHeartbeat={runHeartbeat}
            onNotify={notify}
            onSaved={(settings) => {
              setRuntimeSettings(settings)
            }}
            onSetHeartbeatPaused={setHeartbeatPaused}
            open
            presentation="page"
          />
        ) : (
          <PageShell variant="dashboard">
            <ActivityPanel
              onClear={() => setActivityRecords([])}
              onOpenConversation={openActivityConversation}
              records={activityRecords}
              tokenUsage={tokenUsage}
            />
          </PageShell>
        )}
      </main>
      <AppNotificationViewport
        dispatch={notify}
        notifications={notifications}
      />
      {imageViewerItem && (
        <div
          className="image-viewer-backdrop"
          onMouseDown={(event) => {
            if (event.target === event.currentTarget) {
              closeImageViewer()
            }
          }}
        >
          <section
            aria-labelledby="image-viewer-title"
            aria-modal="true"
            className="image-viewer-dialog"
            onKeyDown={(event) => {
              if (event.key === 'Escape') {
                closeImageViewer()
              }
            }}
            role="dialog"
          >
            <header className="image-viewer-dialog__header">
              <strong id="image-viewer-title">
                {imageViewerItem.title}
              </strong>
              <div>
                <button
                  className="secondary-button"
                  onClick={() => downloadImage(imageViewerItem)}
                  type="button"
                >
                  <Download size={14} />
                  下载图片
                </button>
                <button
                  aria-label="关闭图片查看器"
                  autoFocus
                  className="icon-button"
                  onClick={closeImageViewer}
                  type="button"
                >
                  <X size={16} />
                </button>
              </div>
            </header>
            <div className="image-viewer-dialog__content">
              <img
                alt={imageViewerItem.title}
                src={imageViewerItem.src}
              />
            </div>
          </section>
        </div>
      )}
      {windowCaptureOptions && (
        <div
          className="window-capture-backdrop"
          onMouseDown={(event) => {
            if (event.target === event.currentTarget) {
              setWindowCaptureOptions(undefined)
            }
          }}
        >
          <section
            aria-labelledby="window-capture-title"
            aria-modal="true"
            className="window-capture-dialog"
            onKeyDown={(event) => {
              if (event.key === 'Escape') {
                setWindowCaptureOptions(undefined)
              }
            }}
            role="dialog"
          >
            <div className="window-capture-dialog__header">
              <div>
                <strong id="window-capture-title">选择应用窗口</strong>
                <small>仅捕获所选窗口的当前画面，不会持续监控。</small>
              </div>
              <button
                aria-label="关闭应用窗口选择"
                className="icon-button"
                onClick={() => setWindowCaptureOptions(undefined)}
                type="button"
              >
                ×
              </button>
            </div>
            <div
              aria-label="可捕获的应用窗口"
              className="window-capture-dialog__list"
            >
              {windowCaptureOptions.map((source, index) => (
                <button
                  autoFocus={index === 0}
                  key={source.id}
                  onClick={() => void captureSelectedWindow(source.id)}
                  type="button"
                >
                  <PanelsTopLeft size={16} />
                  <span>{source.name}</span>
                </button>
              ))}
            </div>
            <button
              className="secondary-button"
              onClick={() => setWindowCaptureOptions(undefined)}
              type="button"
            >
              取消
            </button>
          </section>
        </div>
      )}
      <RightAssistantSidebar
        approvals={pendingSidebarApprovals}
        artifacts={sidebarArtifacts}
        attachments={attachments}
        browserState={browserStates[activeId]}
        enabledLibraries={enabledSidebarLibraries}
        heartbeats={assistantHeartbeats}
        memories={assistantMemories}
        schedules={assistantSchedules}
        onClose={() => setAssistantSidebarOpen(false)}
        onInteractBrowser={async () => {
          if (!activeId) {
            return
          }
          const browserApi = window.goodbuddy.browser
          if (!browserApi) {
            notify({
              tone: 'error',
              message: '浏览器控制组件尚未加载，请重启 GoodBuddy'
            })
            return
          }
          await browserApi.interact(activeId)
        }}
        onStopBrowser={async () => {
          if (!activeId) {
            return
          }
          const browserApi = window.goodbuddy.browser
          if (!browserApi) {
            notify({
              tone: 'error',
              message: '浏览器控制组件尚未加载，请重启 GoodBuddy'
            })
            return
          }
          try {
            await browserApi.stop(activeId)
          } catch {
            notify({
              tone: 'error',
              message: '停止浏览器失败，请重试'
            })
          }
        }}
        onCreateHeartbeat={createHeartbeat}
        onCreateSchedule={async (input) => {
          const schedule = await window.goodbuddy.schedules.create({
            ...input,
            projectId: activeProjectId || undefined
          })
          setAssistantSchedules((current) => [schedule, ...current])
        }}
        onImportArtifacts={async () => {
          const imported = await window.goodbuddy.artifacts.importFiles(
            activeProjectId || undefined
          )
          if (imported.length > 0) {
            setAssistantArtifacts((current) => [
              ...imported,
              ...current
            ])
            setAssistantSidebarTab('results')
          }
        }}
        onLoadArtifact={async (artifactId) => {
          if (assistantArtifactById.get(artifactId)?.content) {
            return
          }
          const artifact = await window.goodbuddy.artifacts.get(
            artifactId
          )
          setAssistantArtifacts((current) =>
            mergeArtifacts(current, [artifact])
          )
        }}
        onRemoveAttachment={removeAttachment}
        onRemoveHeartbeat={removeHeartbeat}
        onRemoveSchedule={async (scheduleId) => {
          await window.goodbuddy.schedules.remove(scheduleId)
          setAssistantSchedules((current) =>
            current.filter((schedule) => schedule.id !== scheduleId)
          )
        }}
        onRespondApproval={(approval, decision) => {
          void respondToApproval(
            approval.conversationId,
            approval.messageId,
            approval.approvalId,
            decision
          )
        }}
        onRunHeartbeat={runHeartbeat}
        onRunSchedule={async (scheduleId) => {
          await window.goodbuddy.schedules.runNow(scheduleId)
          notify({ tone: 'success', message: '定时任务已开始执行' })
        }}
        onSetHeartbeatPaused={setHeartbeatPaused}
        onListWorkspaceDirectory={listWorkspaceDirectory}
        onLoadWorkspaceFile={loadWorkspaceFile}
        onOpenWorkspaceEntry={openWorkspaceEntry}
        onRefreshChanges={refreshWorkspaceChanges}
        onTabChange={setAssistantSidebarTab}
        open={
          assistantSidebarOpen && view === 'chat'
        }
        tab={assistantSidebarTab}
        workspaceChanges={workspaceChanges}
        workspaceProjectId={activeProjectId || undefined}
      />
    </div>
  )
}

export default App
