// BooCoder pane — chat + diff inside BooChat's multi-pane workspace.
//
// REST: /api/coder/* proxied by BooChat to host boocoder.service (:9502).
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, X, RefreshCw, Terminal, Puzzle, Sparkles } from 'lucide-react';
import mascot from '@/assets/brand/banner-mascot.png';
import { AgentComposerBar } from '@/components/AgentComposerBar';
import { PermissionCard } from '@/components/PermissionCard';
import { ChatInput } from '@/components/ChatInput';
import type { SlashCommandGroup } from '@/components/SlashCommandPicker';
import { api } from '@/api/client';
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
import { useSkills } from '@/hooks/useSkills';
import { toast } from 'sonner';
import { mergeCommandsByName, parseSlashInput } from "@/lib/slash-command";
import { mergeWireToolCall } from '@/lib/coder-tools';
import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList';
import { providerIcon, providerLabel } from '@/components/coder/providerIcons';
import { refreshAgentSessions } from '@/hooks/useAgentSessions';
import { useAgentStatus, type AgentStatus, type AgentStatusEntry } from '@/hooks/useAgentStatus';
import { cn } from '@/lib/utils';
import { sessionEvents } from '@/hooks/sessionEvents';

interface CoderMessage {
  id: string;
  role: 'user' | 'assistant' | 'system';
  content: string;
  // F1: 'cancelled' — a user Stop / stall finalized the turn (renders "Stopped").
  status?: 'streaming' | 'complete' | 'failed' | 'cancelled';
  // model-attribution: which model produced this assistant message (chip).
  model?: string | null;
  reasoning_text?: string;
  tool_calls?: Array<{
    id: string;
    function: { name: string; arguments: string };
  }>;
  ctx_used?: number | null;
  ctx_max?: number | null;
}

interface CoderToolMessage {
  id: string;
  role: 'tool';
  tool_results: {
    tool_call_id: string;
    output: unknown;
    truncated?: boolean;
    error?: string;
  };
}

type CoderTimelineMessage = CoderMessage | CoderToolMessage;

// Per-chat agent-config cache (provider/model/mode/thinking). Keyed by chat id
// so reopening or switching back to a chat restores the model that was loaded
// last there. Per-device (localStorage) — a UI convenience, not authoritative.
const DEFAULT_AGENT_CONFIG: AgentSessionConfig = {
  provider: 'boocode',
  model: '',
  modeId: null,
  thinkingOptionId: null,
};
function agentConfigKey(chatId: string | undefined): string | null {
  return chatId ? `boocode.coder.config.${chatId}` : null;
}
function readCachedAgentConfig(chatId: string | undefined): AgentSessionConfig | null {
  const key = agentConfigKey(chatId);
  if (!key || typeof localStorage === 'undefined') return null;
  try {
    const raw = localStorage.getItem(key);
    if (!raw) return null;
    const c = JSON.parse(raw) as Partial<AgentSessionConfig>;
    if (typeof c?.provider !== 'string') return null;
    return {
      provider: c.provider,
      model: typeof c.model === 'string' ? c.model : '',
      modeId: c.modeId ?? null,
      thinkingOptionId: c.thinkingOptionId ?? null,
    };
  } catch {
    return null;
  }
}
function writeCachedAgentConfig(chatId: string | undefined, config: AgentSessionConfig): void {
  const key = agentConfigKey(chatId);
  if (!key || typeof localStorage === 'undefined') return;
  try {
    localStorage.setItem(key, JSON.stringify(config));
  } catch {
    /* quota / disabled storage — non-fatal */
  }
}

interface PendingChange {
  id: string;
  file_path: string;
  operation: 'create' | 'modify' | 'delete';
  diff?: string;
  new_content?: string;
  status: 'pending' | 'approved' | 'rejected';
  // v2.6 Phase 1-UX §9a: which agent staged this change. 'boocode' for native
  // write tools, the dispatched agent for worktree edits, null for a manual
  // RightRail-staged create (renders as a neutral "manual" badge).
  agent: string | null;
}

interface Props {
  sessionId: string;
  paneId: string;
  chatId?: string;
  chatPending?: boolean;
  projectPath?: string;
  onConnectedChange?: (connected: boolean) => void;
  onAgentLabelChange?: (label: string) => void;
}

interface WsHandlers {
  onPermissionRequested?: (prompt: PermissionPrompt) => void;
  onPermissionResolved?: (taskId: string) => void;
  onAssistantComplete?: () => void;
  onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void;
  onConnectedChange?: (connected: boolean) => void;
  // #10: normalized external-agent status (working|blocked|idle|error) for the
  // (chat,agent) carried on the frame. CoderPane records it in a live map and
  // feeds the active agent's status to AgentComposerBar's status dot.
  onAgentStatus?: (
    chatId: string,
    agent: string,
    entry: AgentStatusEntry,
  ) => void;
}

type RawCoderMessage = {
  id: string;
  role: string;
  chat_id?: string;
  content?: string | null;
  status?: string | null;
  model?: string | null;
  reasoning_text?: string;
  reasoning_parts?: Array<{ text?: string }> | null;
  tool_results?: {
    tool_call_id: string;
    output: unknown;
    truncated?: boolean;
    error?: string;
  } | null;
  tool_calls?: Array<
    | { id: string; name: string; args?: Record<string, unknown> }
    | { id: string; function: { name: string; arguments: string } }
  > | null;
  ctx_used?: number | null;
  ctx_max?: number | null;
};

function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null {
  if (raw.role === 'tool') {
    if (!raw.tool_results?.tool_call_id) return null;
    return {
      id: raw.id,
      role: 'tool',
      tool_results: raw.tool_results,
    };
  }
  if (raw.role !== 'user' && raw.role !== 'assistant' && raw.role !== 'system') return null;
  const tool_calls = raw.tool_calls?.map((tc) => {
    if ('function' in tc) {
      return { id: tc.id, function: tc.function };
    }
    return {
      id: tc.id,
      function: {
        name: tc.name,
        arguments: JSON.stringify(tc.args ?? {}),
      },
    };
  });
  const reasoning_text =
    raw.reasoning_text ??
    raw.reasoning_parts?.map((p) => p.text ?? '').join('') ??
    '';
  return {
    id: raw.id,
    role: raw.role as CoderMessage['role'],
    content: raw.content ?? '',
    status: (raw.status ?? 'complete') as CoderMessage['status'],
    ...(raw.model ? { model: raw.model } : {}),
    ...(reasoning_text ? { reasoning_text } : {}),
    ...(tool_calls?.length ? { tool_calls } : {}),
    ctx_used: raw.ctx_used ?? null,
    ctx_max: raw.ctx_max ?? null,
  };
}

function useCoderMessages(sessionId: string, chatId: string | undefined, handlers: WsHandlers) {
  const [messages, setMessages] = useState<CoderTimelineMessage[]>([]);
  const [connected, setConnected] = useState(false);
  const wsRef = useRef<WebSocket | null>(null);
  const handlersRef = useRef(handlers);
  handlersRef.current = handlers;
  const chatIdRef = useRef(chatId);
  chatIdRef.current = chatId;

  const loadMessages = useCallback(() => {
    if (!chatId) {
      setMessages([]);
      return Promise.resolve();
    }
    return api.coder
      .listMessages(sessionId, chatId)
      .then((rows) =>
        setMessages(
          rows
            .map(mapCoderTimelineRow)
            .filter((m): m is CoderTimelineMessage => m !== null),
        ),
      )
      .catch(() => {/* boocoder may be down */});
  }, [sessionId, chatId]);

  useEffect(() => {
    void loadMessages();
  }, [loadMessages]);

  useEffect(() => {
    // WS connects to the coder backend. In production, this goes through the
    // same host (BooChat serves the SPA and proxies). In dev, Vite proxy
    // handles /api/coder/ws/* -> boocoder:9502.
    const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const wsUrl = `${proto}//${window.location.host}/api/coder/ws/sessions/${sessionId}`;
    const ws = new WebSocket(wsUrl);
    wsRef.current = ws;

    ws.onopen = () => setConnected(true);
    ws.onclose = () => setConnected(false);

    ws.onmessage = (ev) => {
      try {
        const frame = JSON.parse(ev.data as string);
        const scopedChatId = chatIdRef.current;
        if (
          scopedChatId &&
          frame.chat_id &&
          frame.chat_id !== scopedChatId &&
          frame.type !== 'snapshot'
        ) {
          return;
        }
        if (frame.type === 'snapshot' && Array.isArray(frame.messages)) {
          const rawMessages = (frame.messages as RawCoderMessage[]).filter(
            (m) => !scopedChatId || m.chat_id === scopedChatId,
          );
          setMessages(
            rawMessages
              .map(mapCoderTimelineRow)
              .filter((m): m is CoderTimelineMessage => m !== null),
          );
        } else if (frame.type === 'message_started') {
          setMessages((prev) => {
            if (prev.some((m) => m.id === frame.message_id)) return prev;
            const role = frame.role ?? 'assistant';
            const tempIdx =
              role === 'user'
                ? prev.findIndex((m) => m.id.startsWith('temp-') && m.role === 'user')
                : -1;
            if (tempIdx >= 0) {
              return prev.map((m, i) =>
                i === tempIdx ? { ...m, id: frame.message_id, status: 'streaming' } : m,
              );
            }
            return [
              ...prev,
              { id: frame.message_id, role, content: '', status: 'streaming' },
            ];
          });
        } else if (frame.type === 'delta') {
          setMessages((prev) =>
            prev.map((m) => {
              if (m.id !== frame.message_id || m.role === 'tool') return m;
              const chunk = frame.content ?? '';
              if (m.role === 'user') {
                return { ...m, content: chunk || m.content };
              }
              return { ...m, content: m.content + chunk };
            }),
          );
        } else if (frame.type === 'message_complete') {
          setMessages((prev) => {
            const completed = prev.find(
              (m): m is CoderMessage => m.id === frame.message_id && m.role === 'assistant',
            );
            const next = prev.map((m) =>
              m.id === frame.message_id && m.role !== 'tool'
                ? {
                    ...m,
                    // F1 (D-8): the terminal frame carries an optional status —
                    // 'cancelled' on a Stop/stall, 'failed' on error. Absent on the
                    // normal path → defaults to 'complete'.
                    status: ((frame as any).status ?? 'complete') as CoderMessage['status'],
                    model: (frame as any).model ?? (m as any).model ?? null,
                    ctx_used: (frame as any).ctx_used ?? (m as any).ctx_used ?? null,
                    ctx_max: (frame as any).ctx_max ?? (m as any).ctx_max ?? null,
                  }
                : m,
            );
            if (completed) {
              queueMicrotask(() => handlersRef.current.onAssistantComplete?.());
            }
            return next;
          });
        } else if (frame.type === 'tool_call') {
          const tc = frame.tool_call as { id: string; name: string; args?: Record<string, unknown> } | undefined;
          if (tc?.id) {
            setMessages((prev) =>
              prev.map((m) =>
                m.role !== 'assistant' || m.id !== frame.message_id
                  ? m
                  : { ...m, tool_calls: mergeWireToolCall(m.tool_calls, { ...tc, args: tc.args ?? {} }) },
              ),
            );
          }
        } else if (frame.type === 'tool_result') {
          setMessages((prev) => {
            const exists = prev.some((m) => m.id === frame.tool_message_id);
            if (exists) {
              return prev.map((m) =>
                m.role === 'tool' && m.id === frame.tool_message_id
                  ? {
                      ...m,
                      tool_results: {
                        tool_call_id: frame.tool_call_id,
                        output: frame.output,
                        truncated: frame.truncated,
                        ...(frame.error ? { error: frame.error } : {}),
                      },
                    }
                  : m,
              );
            }
            return [
              ...prev,
              {
                id: frame.tool_message_id,
                role: 'tool' as const,
                tool_results: {
                  tool_call_id: frame.tool_call_id,
                  output: frame.output,
                  truncated: frame.truncated,
                  ...(frame.error ? { error: frame.error } : {}),
                },
              },
            ];
          });
        } else if (frame.type === 'reasoning_delta') {
          setMessages((prev) =>
            prev.map((m) =>
              m.id === frame.message_id && m.role === 'assistant'
                ? { ...m, reasoning_text: (m.reasoning_text ?? '') + (frame.content ?? '') }
                : m,
            ),
          );
        } else if (frame.type === 'permission_requested') {
          handlersRef.current.onPermissionRequested?.({
            taskId: frame.task_id,
            kind: frame.kind,
            toolTitle: frame.tool_title,
            ...(frame.input ? { input: frame.input as Record<string, unknown> } : {}),
            options: (frame.options ?? []).map((o: { option_id: string; label: string }) => ({
              optionId: o.option_id,
              label: o.label,
            })),
          });
        } else if (frame.type === 'permission_resolved') {
          handlersRef.current.onPermissionResolved?.(frame.task_id);
        } else if (frame.type === 'agent_commands') {
          handlersRef.current.onAgentCommands?.(
            frame.task_id,
            (frame.commands ?? []).map((c: { name: string; description?: string }) => ({
              name: c.name,
              description: c.description,
            })),
          );
        } else if (frame.type === 'agent_status_updated') {
          // #10: { chat_id, agent, status, reason?, at }. The chat_id guard
          // above already dropped cross-chat frames; record per (chat,agent).
          const chatId = (frame.chat_id ?? scopedChatId) as string | undefined;
          const agent = frame.agent as string | undefined;
          const status = frame.status as AgentStatus | undefined;
          if (chatId && agent && status) {
            handlersRef.current.onAgentStatus?.(chatId, agent, {
              status,
              ...(frame.reason ? { reason: frame.reason as string } : {}),
              at: (frame.at as string) ?? new Date().toISOString(),
            });
          }
        }
      } catch {
        // ignore unparseable frames
      }
    };

    return () => {
      ws.close();
      wsRef.current = null;
    };
  }, [sessionId]);

  useEffect(() => {
    handlersRef.current.onConnectedChange?.(connected);
  }, [connected]);

  return { messages, setMessages, connected, loadMessages };
}

function usePendingChanges(sessionId: string) {
  const [changes, setChanges] = useState<PendingChange[]>([]);
  const [loading, setLoading] = useState(false);

  const refresh = useCallback(() => {
    setLoading(true);
    fetch(`/api/coder/sessions/${sessionId}/pending`)
      .then((res) => res.ok ? res.json() : [])
      .then((data: PendingChange[]) => setChanges(data))
      .catch(() => {/* noop */})
      .finally(() => setLoading(false));
  }, [sessionId]);

  useEffect(() => { refresh(); }, [refresh]);

  const approve = useCallback(async (changeId: string) => {
    const res = await fetch(`/api/coder/pending/${changeId}/apply`, {
      method: 'POST',
    });
    if (res.ok) {
      setChanges((prev) => prev.map((c) => c.id === changeId ? { ...c, status: 'approved' } : c));
      sessionEvents.emit({ type: 'git_diff_refresh' });
    }
  }, [sessionId]);

  const reject = useCallback(async (changeId: string) => {
    const res = await fetch(`/api/coder/pending/${changeId}/reject`, {
      method: 'POST',
    });
    if (res.ok) {
      setChanges((prev) => prev.map((c) => c.id === changeId ? { ...c, status: 'rejected' } : c));
      sessionEvents.emit({ type: 'git_diff_refresh' });
    }
  }, [sessionId]);

  return { changes, loading, refresh, approve, reject };
}

// write-edit-robustness #4: which assistant messages in this chat have a
// worktree checkpoint, so CoderMessageList can offer "Restore to here" only on
// those. Refetched on message_complete (same trigger as pending changes) and
// after a successful restore.
function useCheckpoints(sessionId: string, chatId: string | undefined) {
  const [messageIds, setMessageIds] = useState<Set<string>>(() => new Set());

  const refresh = useCallback(() => {
    if (!chatId) {
      setMessageIds(new Set());
      return Promise.resolve();
    }
    return api.coder
      .getCheckpoints(sessionId, chatId)
      .then((res) => setMessageIds(new Set(res.checkpoints.map((c) => c.message_id))))
      .catch(() => {/* boocoder may be down / endpoint not ready */});
  }, [sessionId, chatId]);

  useEffect(() => { void refresh(); }, [refresh]);

  return { checkpointMessageIds: messageIds, refreshCheckpoints: refresh };
}

function DiffPanel({
  changes,
  loading,
  currentProvider,
  onRefresh,
  onApprove,
  onReject,
}: {
  changes: PendingChange[];
  loading: boolean;
  currentProvider: string;
  onRefresh: () => void;
  onApprove: (id: string) => void;
  onReject: (id: string) => void;
}) {
  const pending = changes.filter((c) => c.status === 'pending');

  // v2.6 Phase 1-UX §9a: when pending changes span >1 distinct agent, surface a
  // one-line "Changes from <a>, <b>" note so mixed provenance is obvious. Null
  // (manual) counts as its own bucket and renders as "manual".
  const distinctAgents = Array.from(new Set(pending.map((c) => c.agent)));
  const mixedNote =
    distinctAgents.length > 1
      ? `Changes from ${distinctAgents.map((a) => providerLabel(a)).join(', ')}`
      : null;

  // v2.6 §9c: staging-boundary caveat. External agents (opencode/goose/qwen/
  // claude) edit *inside their worktree*; native boocode reads/writes the
  // *project root* via pending_changes. Unapplied edits don't cross that
  // boundary. When the currently-selected provider can't see another side's
  // staged-but-unapplied edits, surface a muted one-liner. agent===null
  // (manual) is boundary-neutral. Pure derivation — no new state/fetch.
  const isNativeProvider = currentProvider === 'boocode';
  const boundaryHint = (() => {
    if (isNativeProvider) {
      // Native boocode is selected: it won't see external-worktree edits.
      const external = distinctAgents.filter((a) => a !== null && a !== 'boocode');
      if (external.length === 0) return null;
      const who =
        external.length === 1
          ? providerLabel(external[0]!)
          : external.map((a) => providerLabel(a)).join(', ');
      return `${who}'s edits live in its worktree — BooCode won't see them until applied.`;
    }
    // An external agent is selected: it won't see boocode's project-root edits.
    if (!distinctAgents.includes('boocode')) return null;
    return `BooCode's edits live in the project root — ${providerLabel(currentProvider)} won't see them until applied.`;
  })();

  return (
    <div className="flex flex-col h-full border-t border-border">
      <div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/30">
        <span className="text-xs font-medium text-muted-foreground">
          Pending Changes {pending.length > 0 && `(${pending.length})`}
        </span>
        <button
          type="button"
          onClick={onRefresh}
          disabled={loading}
          className="inline-flex items-center justify-center size-6 rounded hover:bg-muted text-muted-foreground"
          aria-label="Refresh pending changes"
        >
          <RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
        </button>
      </div>
      {mixedNote && (
        <div className="px-3 py-1 border-b border-border bg-muted/10 text-[11px] text-muted-foreground truncate">
          {mixedNote}
        </div>
      )}
      {boundaryHint && (
        <div
          className="px-3 py-1 border-b border-border bg-muted/10 text-xs text-muted-foreground"
          title={boundaryHint}
        >
          {boundaryHint}
        </div>
      )}
      <div className="flex-1 overflow-y-auto">
        {pending.length === 0 ? (
          <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
            No pending changes
          </div>
        ) : (
          <div className="divide-y divide-border">
            {pending.map((change) => (
              <div key={change.id} className="px-3 py-2">
                <div className="flex items-center justify-between mb-1">
                  <span className="text-xs font-mono text-foreground truncate flex-1 mr-2 inline-flex items-center min-w-0">
                    <span
                      className="inline-flex items-center gap-1 rounded border border-border bg-muted/40 px-1 py-px mr-1.5 text-[10px] font-medium text-muted-foreground shrink-0"
                      title={
                        change.agent === null
                          ? 'Manually staged (no dispatching agent)'
                          : `Staged by ${providerLabel(change.agent)}`
                      }
                    >
                      {providerIcon(change.agent, 11)}
                      <span>{providerLabel(change.agent)}</span>
                    </span>
                    <span className={cn(
                      'inline-block w-1.5 h-1.5 rounded-full mr-1.5 shrink-0',
                      change.operation === 'create' && 'bg-green-500',
                      change.operation === 'modify' && 'bg-yellow-500',
                      change.operation === 'delete' && 'bg-red-500',
                    )} />
                    <span className="truncate">{change.file_path}</span>
                  </span>
                  <div className="flex items-center gap-1 shrink-0">
                    <button
                      type="button"
                      onClick={() => onApprove(change.id)}
                      className="inline-flex items-center justify-center size-6 rounded bg-green-500/10 hover:bg-green-500/20 text-green-600 dark:text-green-400"
                      aria-label="Approve change"
                      title="Approve"
                    >
                      <Check size={12} />
                    </button>
                    <button
                      type="button"
                      onClick={() => onReject(change.id)}
                      className="inline-flex items-center justify-center size-6 rounded bg-red-500/10 hover:bg-red-500/20 text-red-600 dark:text-red-400"
                      aria-label="Reject change"
                      title="Reject"
                    >
                      <X size={12} />
                    </button>
                  </div>
                </div>
                {change.diff && (
                  <pre className="text-[11px] font-mono bg-muted/50 rounded p-2 overflow-x-auto max-h-32 whitespace-pre">
                    {change.diff}
                  </pre>
                )}
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

export function CoderPane({
  sessionId,
  paneId,
  chatId,
  chatPending = false,
  projectPath,
  onConnectedChange,
  onAgentLabelChange,
}: Props) {
  const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>(
    () => readCachedAgentConfig(chatId) ?? DEFAULT_AGENT_CONFIG,
  );
  // Restore the per-chat cached config when the chat changes. The ref guard
  // skips the initial mount (lazy init already loaded it) + StrictMode double-runs.
  const lastLoadedChatRef = useRef<string | undefined>(chatId);
  useEffect(() => {
    const prev = lastLoadedChatRef.current;
    if (prev === chatId) return;
    lastLoadedChatRef.current = chatId;
    // undefined → real id: the pane just resolved its chat. A selection made
    // while chatId was undefined could not be persisted (the key was null), so
    // carry the current in-memory config into the new chat — and persist it —
    // rather than clobbering the user's pick with DEFAULT on the cache miss.
    if (prev === undefined && chatId) {
      const cached = readCachedAgentConfig(chatId);
      if (cached) setAgentConfig(cached);
      else writeCachedAgentConfig(chatId, agentConfig);
      return;
    }
    setAgentConfig(readCachedAgentConfig(chatId) ?? DEFAULT_AGENT_CONFIG);
  }, [chatId, agentConfig]);
  // Persist on user-driven changes only (not on the restore above), so switching
  // chats never clobbers the new chat's cached config with the old one.
  const handleAgentConfigChange = useCallback(
    (next: AgentSessionConfig) => {
      setAgentConfig(next);
      writeCachedAgentConfig(chatId, next);
    },
    [chatId],
  );

  useEffect(() => {
    const parts = [agentConfig.provider || 'boocode'];
    if (agentConfig.model) parts.push(agentConfig.model);
    onAgentLabelChange?.(parts.join(' · '));
  }, [agentConfig.provider, agentConfig.model, onAgentLabelChange]);
  const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
  // F1: true while the Stop POST is in flight — disables the Stop button and makes
  // a rapid double-click a no-op (the abort is idempotent server-side regardless).
  const [stopping, setStopping] = useState(false);
  const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null);
  const [permissionBusy, setPermissionBusy] = useState(false);
  const [providerCommands, setProviderCommands] = useState<AgentCommand[]>([]);
  const [liveTaskCommands, setLiveTaskCommands] = useState<AgentCommand[]>([]);
  const { skills } = useSkills();
  const [_slashState, setSlashState] = useState<{ query: string } | null>(null);

  const displayedCommands = useMemo(() => {
    const base =
      agentConfig.provider === 'boocode'
        ? skills.map((s) => ({ name: s.name, description: s.description }))
        : providerCommands;
    return mergeCommandsByName(base, liveTaskCommands);
  }, [agentConfig.provider, skills, providerCommands, liveTaskCommands]);

  const skillsByName = useMemo(() => new Set(skills.map((s) => s.name)), [skills]);
  const commandsByName = useMemo(
    () => new Set(displayedCommands.map((c) => c.name)),
    [displayedCommands],
  );

  // v2.5.9: segmented slash menu — the active agent's commands first, then
  // BooCoder skills. boocode has no separate "commands" group (it IS native),
  // so it shows only Skills. Empty groups are dropped.
  const agentCommands = useMemo(
    () =>
      agentConfig.provider === 'boocode'
        ? []
        : mergeCommandsByName(providerCommands, liveTaskCommands),
    [agentConfig.provider, providerCommands, liveTaskCommands],
  );
  const skillItems = useMemo(
    () => skills.map((s) => ({ name: s.name, description: s.description })),
    [skills],
  );
  const slashGroups = useMemo(() => {
    const groups: SlashCommandGroup[] = [];
    // Split the active agent's set: native/CLI commands vs plugin skills, each
    // with its own icon. BooCoder skills always come last.
    const agentCmds = agentCommands.filter((c) => c.kind !== 'skill');
    const agentSkills = agentCommands.filter((c) => c.kind === 'skill');
    if (agentCmds.length > 0) {
      groups.push({
        label: `${agentConfig.provider} commands`,
        items: agentCmds,
        icon: <Terminal className="size-3 shrink-0" />,
      });
    }
    if (agentSkills.length > 0) {
      groups.push({
        label: `${agentConfig.provider} skills`,
        items: agentSkills,
        icon: <Puzzle className="size-3 shrink-0" />,
      });
    }
    if (skillItems.length > 0) {
      groups.push({
        label: 'BooCoder skills',
        items: skillItems,
        icon: <Sparkles className="size-3 shrink-0" />,
      });
    }
    return groups;
  }, [agentCommands, skillItems, agentConfig.provider]);

  // #10: live normalized status per (chat,agent), reset on chat switch below.
  const agentStatus = useAgentStatus();
  const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
    onConnectedChange,
    onPermissionRequested: (prompt) => {
      setActiveTaskId(prompt.taskId);
      setPermissionPrompt(prompt);
    },
    onPermissionResolved: (taskId) => {
      if (activeTaskId === taskId || permissionPrompt?.taskId === taskId) {
        setPermissionPrompt(null);
      }
    },
    onAssistantComplete: () => {
      setActiveTaskId(null);
      setPermissionPrompt(null);
      setLiveTaskCommands([]);
    },
    onAgentCommands: (_taskId, commands) => {
      setLiveTaskCommands(commands);
    },
    onAgentStatus: agentStatus.record,
  });

  // Clear any stale status for the previous chat when the pane switches chats so
  // a lingering working/blocked dot never carries into the next conversation.
  useEffect(() => {
    return () => agentStatus.reset(chatId);
  }, [chatId, agentStatus]);

  // The active agent's normalized status for this chat. null for native boocode
  // (no external status published) or before any frame arrives — gates the dot.
  const currentAgentStatus: AgentStatusEntry | null =
    agentConfig.provider && agentConfig.provider !== 'boocode'
      ? agentStatus.get(chatId, agentConfig.provider)
      : null;
  const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
  const { checkpointMessageIds, refreshCheckpoints } = useCheckpoints(sessionId, chatId);
  const [input, setInput] = useState('');
  const [sending, setSending] = useState(false);
  const [queue, setQueue] = useState<string[]>([]);
  const queueProcessing = useRef(false);
  const inputRef = useRef<HTMLTextAreaElement>(null);
  // The agent is "generating" during the dispatch POST (sending) AND while its
  // task runs (activeTaskId). sending alone is too brief — it clears the moment
  // dispatch returns — so queueing/stop must key on this combined signal.
  const generating = sending || activeTaskId !== null;

  // Refresh pending changes (and agent-session state for the §9b chip) when a
  // message_complete arrives — same trigger usePendingChanges already uses.
  // write-edit-robustness #4: also refetch checkpoints so a new turn's snapshot
  // surfaces its "Restore to here" control.
  useEffect(() => {
    const lastAssistant = [...messages].reverse().find(
      (m): m is CoderMessage => m.role === 'assistant',
    );
    if (lastAssistant?.status === 'complete') {
      refresh();
      void refreshCheckpoints();
      void refreshAgentSessions(sessionId);
    }
  }, [messages, refresh, refreshCheckpoints, sessionId]);

  // Poll fallbacks when WS is disconnected (reconnect uses WS as source of truth)
  useEffect(() => {
    if (!activeTaskId || connected) return;
    const interval = setInterval(() => {
      if (!permissionPrompt) {
        void api.coder
          .getTaskPermission(activeTaskId)
          .then((prompt) => {
            setPermissionPrompt({
              taskId: prompt.taskId,
              toolTitle: prompt.toolTitle,
              options: prompt.options,
            });
          })
          .catch(() => {/* no pending permission */});
      }
      void api.coder
        .getTaskCommands(activeTaskId)
        .then((res) => setLiveTaskCommands(res.commands))
        .catch(() => {/* not cached yet */});
      void api.coder
        .getTask(activeTaskId)
        .then((task) => {
          if (task.state === 'running' || task.state === 'pending' || task.state === 'blocked') {
            return;
          }
          setActiveTaskId(null);
          setPermissionPrompt(null);
          setLiveTaskCommands([]);
          void loadMessages();
        })
        .catch(() => {/* task gone */});
    }, 2000);
    return () => clearInterval(interval);
  }, [activeTaskId, connected, permissionPrompt, loadMessages]);

  const handleProviderCommandsChange = useCallback((commands: AgentCommand[]) => {
    setProviderCommands(commands);
  }, []);

  const handlePermissionRespond = useCallback(async (optionId: string | null, updatedInput?: Record<string, unknown>) => {
    if (!permissionPrompt) return;
    setPermissionBusy(true);
    try {
      await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId, updatedInput);
      setPermissionPrompt(null);
    } finally {
      setPermissionBusy(false);
    }
  }, [permissionPrompt]);

  const handleSend = useCallback(async () => {
    const text = input.trim();
    if (!text || sending || !chatId) return;

    if (text.startsWith('/')) {
      const parsed = parseSlashInput(text);
      if (parsed) {
        const { cmdName, args } = parsed;
        if (agentConfig.provider === 'boocode' && skillsByName.has(cmdName)) {
          setInput('');
          setSlashState(null);
          setSending(true);
          setPermissionPrompt(null);
          setLiveTaskCommands([]);
          try {
            await api.coder.skillInvoke(
              sessionId,
              paneId,
              cmdName,
              args.length > 0 ? args : null,
            );
          } catch (err) {
            toast.error(err instanceof Error ? err.message : 'skill invocation failed');
          } finally {
            setSending(false);
          }
          return;
        }
        if (!commandsByName.has(cmdName)) {
          // Unknown slash — fall through and send as literal text.
        }
      }
    }

    setInput('');
    setSlashState(null);
    setSending(true);
    setPermissionPrompt(null);
    setLiveTaskCommands([]);

    const tempId = `temp-${Date.now()}`;
    setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]);

    try {
      const data = await api.coder.sendMessage(sessionId, {
        content: text,
        pane_id: paneId,
        chat_id: chatId,
        provider: agentConfig.provider !== 'boocode' ? agentConfig.provider : undefined,
        model: agentConfig.model || undefined,
        mode_id: agentConfig.modeId ?? undefined,
        thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
      });
      if (data.user_message_id) {
        setMessages((prev) =>
          prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m))
        );
      }
      if (data.task_id) {
        setActiveTaskId(data.task_id);
      } else {
        setActiveTaskId(null);
      }
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'failed to send');
    } finally {
      setSending(false);
    }
  }, [
    input,
    sending,
    sessionId,
    paneId,
    chatId,
    agentConfig,
    skillsByName,
    commandsByName,
    setMessages,
  ]);


  const sendOneMessage = useCallback(async (text: string) => {
    if (!chatId) return;
    setSending(true);
    setPermissionPrompt(null);
    setLiveTaskCommands([]);

    const tempId = `temp-${Date.now()}`;
    setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]);

    try {
      const data = await api.coder.sendMessage(sessionId, {
        content: text,
        pane_id: paneId,
        chat_id: chatId,
        provider: agentConfig.provider !== 'boocode' ? agentConfig.provider : undefined,
        model: agentConfig.model || undefined,
        mode_id: agentConfig.modeId ?? undefined,
        thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
      });
      if (data.user_message_id) {
        setMessages((prev) =>
          prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m))
        );
      }
      if (data.task_id) {
        setActiveTaskId(data.task_id);
      } else {
        setActiveTaskId(null);
      }
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'failed to send');
    } finally {
      setSending(false);
    }
  }, [sessionId, paneId, chatId, agentConfig, setMessages]);

  // Drain queue once the agent is idle (not just past the dispatch POST).
  useEffect(() => {
    if (generating || queue.length === 0 || queueProcessing.current) return;
    queueProcessing.current = true;
    const next = queue[0]!;
    setQueue((prev) => prev.slice(1));
    sendOneMessage(next).finally(() => { queueProcessing.current = false; });
  }, [generating, queue, sendOneMessage]);

  const handleChatInputSend = useCallback(async (content: string) => {
    const text = content.trim();
    if (!text || !chatId) return;
    if (generating) {
      setQueue((prev) => [...prev, text]);
      return;
    }
    await sendOneMessage(text);
  }, [generating, chatId, sendOneMessage]);

  const handleStop = useCallback(async () => {
    const taskId = activeTaskId;
    if (!taskId || stopping) return; // ignore a second Stop while the POST is in flight
    setStopping(true);
    try {
      await api.coder.cancelTask(taskId);
      setActiveTaskId(null); // optimistic; WS/poll terminal-state also clears it
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'stop failed');
    } finally {
      setStopping(false);
    }
  }, [activeTaskId, stopping]);

  // write-edit-robustness #4: reset the worktree to a message's checkpoint and
  // trim the transcript past it. The confirm lives in MessageBubble's ActionRow
  // (plain Cancel/Restore). The restore route is keyed by checkpoint id, so we
  // resolve message→checkpoint via a fresh GET (cheap, and avoids a stale id if
  // the set changed). On success, refetch messages so the trimmed transcript
  // shows, plus checkpoints (later ones were deleted server-side) and pending
  // changes (the worktree was reset).
  const handleRestoreCheckpoint = useCallback(async (_chatId: string, messageId: string) => {
    if (!chatId || generating) return;
    let checkpointId: string | undefined;
    try {
      const res = await api.coder.getCheckpoints(sessionId, chatId);
      checkpointId = res.checkpoints.find((c) => c.message_id === messageId)?.id;
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'failed to load checkpoint');
      return;
    }
    if (!checkpointId) {
      toast.error('No checkpoint found for this message');
      return;
    }
    try {
      await api.coder.restoreCheckpoint(sessionId, checkpointId);
      await loadMessages();
      await refreshCheckpoints();
      refresh();
      toast.success('Restored to checkpoint');
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'restore failed');
    }
  }, [chatId, generating, sessionId, loadMessages, refreshCheckpoints, refresh]);

  const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
    if (!chatId) return;
    // Only BooCoder skills route here; an agent's own commands (not skills) fall
    // through to a literal send in ChatInput. Skills run under the active
    // provider: boocode → native inference; external → body injected into a task.
    if (!skillsByName.has(skillName)) return;
    setSending(true);
    setPermissionPrompt(null);
    setLiveTaskCommands([]);
    try {
      const data = await api.coder.skillInvoke(
        sessionId,
        paneId,
        skillName,
        userMessage.length > 0 ? userMessage : null,
        agentConfig.provider !== 'boocode'
          ? {
              provider: agentConfig.provider,
              model: agentConfig.model || undefined,
              mode_id: agentConfig.modeId ?? undefined,
              thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
            }
          : undefined,
      );
      if (data.task_id) setActiveTaskId(data.task_id);
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'skill invocation failed');
    } finally {
      setSending(false);
    }
  }, [chatId, sessionId, paneId, agentConfig, skillsByName]);

  return (
    <div className="flex flex-col h-full bg-background">
      <AgentComposerBar
        projectPath={projectPath}
        value={agentConfig}
        onChange={handleAgentConfigChange}
        onProviderCommandsChange={handleProviderCommandsChange}
        connected={connected}
        agentStatus={currentAgentStatus}
      />
      {/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
      <div className="flex-1 min-h-0 flex flex-col">
        {messages.length === 0 ? (
          <div className="flex flex-col items-center justify-center flex-1 text-sm text-muted-foreground gap-3">
            <img src={mascot} alt="BooCode" draggable={false} className="w-20 h-auto select-none" />
            <p>{chatPending || !chatId ? 'Preparing pane chat…' : 'Send a message to start coding'}</p>
          </div>
        ) : (
          <CoderMessageList
            messages={messages as CoderTimelineWire[]}
            chatId={chatId}
            checkpointMessageIds={checkpointMessageIds}
            restoreDisabled={generating}
            actions={{
              onResend: async (_chatId, content) => { await sendOneMessage(content); },
              onRestoreCheckpoint: handleRestoreCheckpoint,
            }}
            footer={
              activeTaskId && !permissionPrompt && sending === false ? (
                <p className="text-xs text-muted-foreground animate-pulse">Agent running…</p>
              ) : undefined
            }
          />
        )}
      </div>

      {permissionPrompt && (
        <PermissionCard
          prompt={permissionPrompt}
          onRespond={(id, input) => void handlePermissionRespond(id, input)}
          busy={permissionBusy}
        />
      )}

      {/* Diff panel — only shows when there are pending changes */}
      {changes.filter((c) => c.status === 'pending').length > 0 && (
        <div className="h-48 shrink-0">
          <DiffPanel
            changes={changes}
            loading={loading}
            currentProvider={agentConfig.provider}
            onRefresh={refresh}
            onApprove={approve}
            onReject={reject}
          />
        </div>
      )}

      {/* Composer + input */}
      <div className="shrink-0 border-t border-border">
        <ChatInput
          disabled={!chatId || chatPending}
          projectId={projectPath ?? ''}
          onSend={handleChatInputSend}
          generating={generating}
          onStop={handleStop}
          stopDisabled={stopping}
          onSlashCommand={handleChatInputSlash}
          slashGroups={slashGroups}
          chatId={chatId ?? undefined}
          chatLabel="BooCode"
          messages={messages as unknown as import('@/api/types').Message[]}
          modelContextLimit={null}
        />
      </div>
    </div>
  );
}
