import type {
  ApprovalRequest,
  AssistantContentPart,
  LlmMetaChangedFile,
  MarkdownCodeBlockPreview,
  Message,
  ToolCall,
  ToolResult,
} from "#cli/core/model";
import type { ComputedRef } from "vue";
import { createEnvReader, readProcessEnv } from "#cli/core/env";
import { splitMarkdownByFencedCodeBlocks } from "#cli/core/markdown";
import {
  isOneShotEnabled,
  isPerfEnabled,
  perfLog as perfLogLine,
} from "#cli/core/observability/perf-log";
import {
  createChecklistPartId,
  createLocalInteractivePartId,
  createLocalThinkingPartId,
} from "#cli/core/transcript-part-id";
import { isTerminalStatusText, t } from "#cli/i18n";
import { computed } from "vue";

interface MessageStoreState {
  messages: Message[];
  sessionHistoryDirty?: boolean;
  revertedMessages: Message[];
  userHistory: string[];
  layoutDirtyMessageId: string | null;
  layoutDirtySeq: number;
}

type MessageMutationHooks = Readonly<{
  onMessageAdded?: (message: Message) => void;
  onMessageReplaced?: (prev: Message, next: Message) => void;
  onMessagesRemoved?: (removed: Message[]) => void;
  onMessagesRestored?: (restored: Message[]) => void;
}>;

export function createMessageMutations(
  state: MessageStoreState,
  hooks: MessageMutationHooks = {},
): Readonly<{
  pushMessage: (m: Message) => void;
  setSessionHistoryDirty: (value: boolean) => void;
  replaceMessage: (messageId: string, next: Message) => void;
  updateAssistant: (
    messageId: string,
    fn: (m: Extract<Message, { role: "assistant" }>) => Extract<Message, { role: "assistant" }>,
  ) => void;
  setAssistantStatus: (messageId: string, text: string) => void;
  setAssistantThinking: (assistantId: string, content: string, partId: string) => void;
  startNewThinkingBlock: (assistantId: string, partId: string) => void;
  endThinkingBlock: (assistantId: string, partId: string, content?: string) => void;
  appendAssistantMarkdown: (assistantId: string, delta: string) => void;
  appendInfoPart: (
    assistantId: string,
    text: string,
    meta?: Readonly<Record<string, unknown>>,
  ) => void;
  upsertSubagentInfo: (
    assistantId: string,
    subagentId: string,
    kind: "header" | "activity" | "finished",
    text: string,
  ) => void;
  tickSubagentFrame: (assistantId: string) => void;
  appendAssistantLlmMeta: (
    assistantId: string,
    info: Readonly<{
      durationMs: number;
      modelId?: string;
      toolCalls?: number;
      tokensUsed?: number;
      changedFiles?: readonly string[];
      changedFileDetails?: readonly LlmMetaChangedFile[];
      isFinalRound?: boolean;
    }>,
  ) => void;
  updateAssistantLlmMeta: (
    assistantId: string,
    info: Readonly<{
      durationMs?: number;
      modelId?: string;
      toolCalls?: number;
      tokensUsed?: number;
      changedFiles?: readonly string[];
      changedFileDetails?: readonly LlmMetaChangedFile[];
    }>,
  ) => void;
  upsertToolCallDelta: (
    assistantId: string,
    callId: string,
    toolName: string | undefined,
    argsTextDelta: string | undefined,
    linesWritten?: number,
  ) => void;
  upsertToolCallEnd: (
    assistantId: string,
    callId: string,
    toolName: string,
    args: Record<string, unknown>,
    thoughtSignature?: string,
  ) => void;
  upsertToolOutputStart: (assistantId: string, toolCallId: string, toolName: string) => void;
  appendToolOutputDelta: (
    assistantId: string,
    toolCallId: string,
    toolName: string,
    delta: string,
  ) => void;
  attachToolResultPart: (assistantId: string, result: ToolResult) => void;
  insertApprovalPart: (assistantId: string, request: ApprovalRequest) => void;
  toggleChecklist: (messageId: string, partType: "todo" | "plan", itemId: string) => void;
  toggleBlockCollapsed: (
    messageId: string,
    partType: "todo" | "plan" | "thinking",
    partId: string,
  ) => void;
  revertFromMessage: (messageId: string) => number;
  restoreRevertedMessages: () => number;
  clearRevertedMessages: () => void;
  rebuildUserHistory: () => void;
  hasRevertedMessages: ComputedRef<boolean>;
  revertedMessageCount: ComputedRef<number>;
  upsertChecklistPartFromDimSdk: (
    assistantId: string,
    kind: "todo" | "plan",
    toolCallId: string,
    structured: any,
  ) => void;
}> {
  const userHistoryIndexByMessageId = new Map<string, number>();
  const env = createEnvReader(readProcessEnv());
  const perfEnabled = isPerfEnabled();
  const perfStreamEnabled =
    perfEnabled && (isOneShotEnabled() || env.prefixedStr("PROFILE_STREAM_MUTATIONS") === "1");
  let lastStreamSlowLogAt = 0;

  function perfLogStream(line: string): void {
    if (!perfEnabled) return;
    perfLogLine(line);
  }

  const streamMarkdownTailLimit = (() => {
    const raw = env.prefixedStr("STREAM_MARKDOWN_TAIL_CHARS");
    const n = Number(raw);
    if (Number.isFinite(n) && n > 0) return Math.min(5_000_000, Math.max(2000, Math.floor(n)));
    return 30_000;
  })();
  const streamMarkdownChunkThreshold = (() => {
    const raw = env.prefixedStr("STREAM_MARKDOWN_CHUNK_THRESHOLD_CHARS");
    const n = Number(raw);
    if (Number.isFinite(n) && n > 0) return Math.min(5_000_000, Math.max(2000, Math.floor(n)));
    return 20_000;
  })();

  const streamToolOutputTailLimit = (() => {
    const raw = env.prefixedStr("STREAM_TOOL_OUTPUT_TAIL_CHARS");
    const n = Number(raw);
    if (Number.isFinite(n) && n > 0) return Math.min(5_000_000, Math.max(2000, Math.floor(n)));
    return 30_000;
  })();

  const streamToolOutputTailLinesLimit = (() => {
    const raw = env.prefixedStr("STREAM_TOOL_OUTPUT_TAIL_LINES");
    const n = Number(raw);
    if (Number.isFinite(n) && n > 0) return Math.min(50_000, Math.max(20, Math.floor(n)));
    return 2000;
  })();

  const streamToolArgsTailLimit = (() => {
    const raw = env.prefixedStr("STREAM_TOOL_ARGS_TAIL_CHARS");
    const n = Number(raw);
    if (Number.isFinite(n) && n > 0) return Math.min(5_000_000, Math.max(2000, Math.floor(n)));
    return 30_000;
  })();

  function getMarkdownCodeBlockHash(language: string, code: string): string {
    const str = `${String(language ?? "").trim() || "text"}\0${String(code ?? "").replace(/\r/g, "")}`;
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
      const ch = str.charCodeAt(i);
      hash = (hash << 5) - hash + ch;
      hash = hash & hash;
    }
    return hash.toString(36);
  }

  function pruneMarkdownCodeBlockPreviews(
    markdown: string,
    existing: readonly MarkdownCodeBlockPreview[] | undefined,
  ): readonly MarkdownCodeBlockPreview[] | undefined {
    if (!Array.isArray(existing) || existing.length === 0) return undefined;
    const codeBlocks = splitMarkdownByFencedCodeBlocks(markdown).filter(
      (
        block,
      ): block is Extract<
        ReturnType<typeof splitMarkdownByFencedCodeBlocks>[number],
        { type: "code" }
      > => block.type === "code",
    );
    if (codeBlocks.length === 0) return undefined;

    const max = Math.min(existing.length, codeBlocks.length);
    let keep = 0;
    for (; keep < max; keep++) {
      const preview = existing[keep];
      const block = codeBlocks[keep];
      if (!preview || !block) break;
      const language = String(block.language ?? "").trim() || "text";
      const code = String(block.code ?? "").replace(/\r/g, "");
      const totalLines = code.split("\n").length;
      if (
        preview.hash !== getMarkdownCodeBlockHash(language, code) ||
        preview.totalLines !== totalLines
      ) {
        break;
      }
    }

    return keep > 0 ? existing.slice(0, keep) : undefined;
  }

  function countNewlines(text: string): number {
    const s = String(text ?? "");
    if (!s) return 0;
    let n = 0;
    for (let i = 0; i < s.length; i++) {
      // '\n'
      if (s.charCodeAt(i) === 10) n++;
    }
    return n;
  }

  function computeTailLinesFromTailText(tail: string, maxLines: number): string[] {
    const cap = Math.max(1, Math.floor(maxLines) + 1);
    const segs = String(tail ?? "")
      .replace(/\r/g, "")
      .split("\n");
    if (segs.length > cap) return segs.slice(-cap);
    return segs;
  }

  function normalizeUserHistoryText(v: unknown): string {
    const s = String(v ?? "");
    // Match ChatPage behavior: normalize newlines + trim + ignore empty.
    return s.replace(/\r\n?/g, "\n").trim();
  }

  function rebuildUserHistory(): void {
    userHistoryIndexByMessageId.clear();
    state.userHistory.length = 0;
    for (const m of state.messages) {
      if (!m || m.role !== "user") continue;
      const normalized = normalizeUserHistoryText((m as any).content);
      if (!normalized) continue;
      const idx = state.userHistory.length;
      state.userHistory.push(normalized);
      userHistoryIndexByMessageId.set(m.id, idx);
    }
  }

  function upsertUserHistoryForMessage(m: Message): void {
    if (!m || m.role !== "user") return;
    const id = String(m.id ?? "").trim();
    if (!id) return;

    const normalized = normalizeUserHistoryText((m as any).content);
    const existing = userHistoryIndexByMessageId.get(id);

    if (!normalized) {
      if (existing == null) return;
      // Removing an entry shifts indices; rebuild to keep the map consistent.
      rebuildUserHistory();
      return;
    }

    if (existing == null) {
      const idx = state.userHistory.length;
      state.userHistory.push(normalized);
      userHistoryIndexByMessageId.set(id, idx);
      return;
    }

    if (state.userHistory[existing] !== normalized) state.userHistory[existing] = normalized;
  }

  function markLayoutDirtyIfNonTail(messageId: string): void {
    const id = String(messageId ?? "").trim();
    if (!id) return;

    // The layout model treats the last 5 *renderable* messages (non-tool) as the "tail"
    // and updates them on every frame. Older messages need a dirty hint.
    //
    // Streaming updates usually touch only the tail. Keep the common case fast by scanning
    // backwards and early-returning when the id is within the tail window.
    const tail = 5;
    let seenRenderable = 0;
    for (let i = state.messages.length - 1; i >= 0; i--) {
      const m = state.messages[i];
      if (!m || m.role === "tool") continue;
      if (m.id === id) {
        if (seenRenderable < tail) return;
        break;
      }
      seenRenderable++;
      if (seenRenderable >= tail) break;
    }
    state.layoutDirtyMessageId = id;
    state.layoutDirtySeq++;
  }

  function normalizeInteractivePartIds(message: Message): Message {
    if (message.role !== "assistant") return message;

    let thinkingOrdinal = 0;
    let todoOrdinal = 0;
    let planOrdinal = 0;
    let changed = false;
    const parts = message.parts.map((part) => {
      if (part.type === "thinking") {
        const existingPartId = String(part.partId ?? "").trim();
        const partId = existingPartId || createLocalThinkingPartId(message.id, thinkingOrdinal);
        thinkingOrdinal += 1;
        if (existingPartId) return part;
        changed = true;
        return { ...part, partId } as const;
      }
      if (part.type === "todo" || part.type === "plan") {
        const existingPartId = String(part.partId ?? "").trim();
        const ordinal = part.type === "todo" ? todoOrdinal++ : planOrdinal++;
        const partId =
          existingPartId ||
          (part.toolCallId
            ? createChecklistPartId(part.type, part.toolCallId)
            : createLocalInteractivePartId(part.type, message.id, ordinal));
        if (existingPartId) return part;
        changed = true;
        return { ...part, partId } as const;
      }
      return part;
    });

    return changed ? { ...message, parts } : message;
  }

  function pushMessage(m: Message): void {
    const next = normalizeInteractivePartIds(m);
    state.messages.push(next);
    upsertUserHistoryForMessage(next);
    hooks.onMessageAdded?.(next);
  }

  function setSessionHistoryDirty(value: boolean): void {
    if (typeof state.sessionHistoryDirty === "boolean") state.sessionHistoryDirty = value;
  }

  function replaceMessage(messageId: string, next: Message): void {
    const normalizedNext = normalizeInteractivePartIds(next);
    const idx = state.messages.findIndex((m) => m.id === messageId);
    if (idx >= 0) {
      const prev = state.messages[idx];
      state.messages[idx] = normalizedNext;
      hooks.onMessageReplaced?.(prev, normalizedNext);
    }
    upsertUserHistoryForMessage(normalizedNext);
    markLayoutDirtyIfNonTail(messageId);
  }

  function updateAssistant(
    messageId: string,
    fn: (m: Extract<Message, { role: "assistant" }>) => Extract<Message, { role: "assistant" }>,
  ): void {
    const msg = state.messages.find((m) => m.id === messageId);
    if (!msg || msg.role !== "assistant") return;
    replaceMessage(messageId, fn(msg));
  }

  function setAssistantStatus(messageId: string, text: string): void {
    updateAssistant(messageId, (m) => {
      const parts = m.parts.map((p) =>
        p.type === "status" ? ({ type: "status", text } as const) : p,
      );

      // If a large streaming markdown part used chunked storage, finalize it once the status
      // becomes terminal. This ensures snapshots/persistence can capture the full output.
      const isTerminal = isTerminalStatusText(text);
      if (!isTerminal) return { ...m, parts };

      let changed = false;
      const finalized = parts.map((p: any) => {
        if (p?.type !== "markdown") return p;
        const chunks: unknown = p.__chunks;
        if (!Array.isArray(chunks) || chunks.length === 0) return p;
        const joined = chunks.join("");
        changed = true;
        const next = { ...p, markdown: joined } as any;
        delete next.__chunks;
        delete next.__fullLen;
        delete next.__tailLimit;
        return next;
      });

      return changed ? { ...m, parts: finalized as any } : { ...m, parts };
    });
  }

  function setAssistantThinking(assistantId: string, content: string, partId: string): void {
    const nextContent = String(content ?? "");
    const normalizedPartId = String(partId ?? "").trim();
    if (!normalizedPartId) return;
    updateAssistant(assistantId, (m) => {
      const parts = m.parts.slice();
      const idx = parts.findIndex(
        (p) => p.type === "thinking" && String(p.partId ?? "").trim() === normalizedPartId,
      );

      if (idx >= 0) {
        const prev = parts[idx] as Extract<(typeof parts)[number], { type: "thinking" }>;
        parts[idx] = {
          type: "thinking",
          partId: prev.partId,
          title: prev.title,
          collapsed: prev.collapsed,
          content: nextContent,
        } as const;
        return { ...m, parts };
      }
      const insertAt = Math.max(0, parts.findIndex((p) => p.type === "status") + 1);
      parts.splice(insertAt, 0, {
        type: "thinking",
        partId: normalizedPartId,
        title: t("chatParts.thinking"),
        collapsed: true,
        content: nextContent,
      } as const);
      return { ...m, parts };
    });
  }

  /**
   * Start a new thinking block. Creates a fresh thinking part with empty content.
   * Used when a new thinking_start event is received.
   *
   * IMPORTANT: Always appends to the END of the parts array.
   * This ensures that thinking blocks appear in the correct temporal order
   * relative to other parts (like tool_calls) that are added during streaming.
   *
   * During streaming, events arrive in this order:
   * - thinking_start -> thinking_delta -> thinking_end (first thinking block)
   * - tool_call (tool call appears after first thinking)
   * - thinking_start -> thinking_delta -> thinking_end (second thinking block)
   *
   * By appending to the end, we preserve this temporal order.
   */
  function startNewThinkingBlock(assistantId: string, partId: string): void {
    const normalizedPartId = String(partId ?? "").trim();
    if (!normalizedPartId) return;
    updateAssistant(assistantId, (m) => {
      const parts = m.parts.slice();
      const existingIdx = parts.findIndex(
        (part) => part.type === "thinking" && String(part.partId ?? "").trim() === normalizedPartId,
      );
      if (existingIdx >= 0) return m;
      // Always append to the end to preserve temporal order during streaming
      // This ensures thinking blocks appear in the correct sequence relative
      // to tool_calls and other parts that are added during streaming
      // Default collapsed; user can toggle to expand.
      parts.push({
        type: "thinking",
        partId: normalizedPartId,
        title: t("chatParts.thinking"),
        collapsed: true,
        content: "",
      } as const);
      return { ...m, parts };
    });
  }

  /**
   * End a thinking block by optionally updating content.
   * Used when a thinking_end event is received.
   *
   * @param assistantId - The ID of the assistant message
   * @param partId - The stable identity of the thinking block
   * @param content - Optional final content to set on the thinking block
   */
  function endThinkingBlock(assistantId: string, partId: string, content?: string): void {
    const normalizedPartId = String(partId ?? "").trim();
    if (!normalizedPartId) return;
    updateAssistant(assistantId, (m) => {
      const parts = m.parts.slice();
      const idx = parts.findIndex(
        (p) => p.type === "thinking" && String(p.partId ?? "").trim() === normalizedPartId,
      );

      if (idx >= 0) {
        const prev = parts[idx] as Extract<(typeof parts)[number], { type: "thinking" }>;
        parts[idx] = {
          type: "thinking",
          partId: prev.partId,
          title: prev.title,
          collapsed: prev.collapsed,
          content: content !== undefined ? content : prev.content,
        } as const;
        return { ...m, parts };
      }
      return m;
    });
  }

  function appendAssistantMarkdown(assistantId: string, delta: string): void {
    const d = String(delta ?? "");
    if (!d) return;
    updateAssistant(assistantId, (m) => {
      const last = m.parts[m.parts.length - 1];
      if (last?.type === "markdown") {
        const prev = last as any;
        const buildNextPart = (markdown: string, extra: Record<string, unknown>) => {
          const nextPrev = { ...prev } as any;
          const nextCodeBlocks = pruneMarkdownCodeBlockPreviews(
            markdown,
            Array.isArray(prev.codeBlocks) ? prev.codeBlocks : undefined,
          );
          if (nextCodeBlocks) nextPrev.codeBlocks = nextCodeBlocks;
          else delete nextPrev.codeBlocks;
          return {
            ...nextPrev,
            type: "markdown",
            markdown,
            ...extra,
          } as const;
        };
        const prevChunks: unknown = prev.__chunks;
        if (Array.isArray(prevChunks)) {
          // Chunked mode: keep full content as chunks, but only maintain a small tail string
          // to avoid O(n^2) string concatenation as the message grows.
          prevChunks.push(d);
          const nextTail = `${String(prev.markdown ?? "")}${d}`;
          const markdown =
            nextTail.length > streamMarkdownTailLimit
              ? nextTail.slice(-streamMarkdownTailLimit)
              : nextTail;
          (m.parts as any[])[m.parts.length - 1] = buildNextPart(markdown, {
            __chunks: prevChunks,
            __fullLen: Number(prev.__fullLen ?? 0) + d.length,
            __tailLimit: streamMarkdownTailLimit,
            __appendSeq: Number(prev.__appendSeq ?? 0) + 1,
            __lastAppendDelta: d,
          });
          return { ...m, parts: m.parts };
        }

        const prevText = String(prev.markdown ?? "");
        const nextText = `${prevText}${d}`;
        if (nextText.length >= streamMarkdownChunkThreshold) {
          const chunks = [prevText, d];
          const markdown =
            nextText.length > streamMarkdownTailLimit
              ? nextText.slice(-streamMarkdownTailLimit)
              : nextText;
          (m.parts as any[])[m.parts.length - 1] = buildNextPart(markdown, {
            __chunks: chunks,
            __fullLen: nextText.length,
            __tailLimit: streamMarkdownTailLimit,
            __appendSeq: Number(prev.__appendSeq ?? 0) + 1,
            __lastAppendDelta: d,
          });
          return { ...m, parts: m.parts };
        }

        // Small markdown: keep as a single string.
        // Streaming optimization: mutate in-place to avoid allocating a new parts array per chunk.
        (m.parts as any[])[m.parts.length - 1] = buildNextPart(nextText, {
          __appendSeq: Number(prev.__appendSeq ?? 0) + 1,
          __lastAppendDelta: d,
        });
        return { ...m, parts: m.parts };
      }
      // Add new part (also in-place to avoid allocations during streaming).
      (m.parts as any[]).push({
        type: "markdown",
        markdown: d,
        __appendSeq: 1,
        __lastAppendDelta: d,
      } as const);
      return { ...m, parts: m.parts };
    });
  }

  function appendInfoPart(
    assistantId: string,
    text: string,
    meta?: Readonly<Record<string, unknown>>,
  ): void {
    const t = String(text ?? "").trim();
    const hasReasoningMeta = typeof meta?._reasoning === "string";
    if (!t && !hasReasoningMeta) return;
    updateAssistant(assistantId, (m) => {
      (m.parts as any[]).push({ type: "info", text: t, ...meta } as const);
      return { ...m, parts: m.parts };
    });
  }

  /**
   * Upsert a subagent activity info part.
   * - `header`: appends a new header line for the task
   * - `activity`: replaces the last subagent activity line (ticker during run)
   * - `finished`: appends/replaces a finished line WITHOUT removing the last activity
   *   so the final display shows: ⎿ last_tool  →  ⎿ done
   */
  function upsertSubagentInfo(
    assistantId: string,
    subagentId: string,
    kind: "header" | "activity" | "finished",
    text: string,
  ): void {
    const t = String(text ?? "").trim();
    if (!t) return;
    updateAssistant(assistantId, (m) => {
      const parts = m.parts as any[];

      // Find the insertion point: right after the last part belonging to
      // this subagentId.  This keeps header → activity → finished grouped
      // together even when multiple subagents run concurrently.
      const findInsertAfter = (): number => {
        for (let i = parts.length - 1; i >= 0; i--) {
          if (parts[i]?._subagentId === subagentId) return i + 1;
        }
        return parts.length;
      };

      if (kind === "header") {
        parts.push({
          type: "info",
          text: t,
          _subagent: "header",
          _subagentId: subagentId,
        } as const);
      } else if (kind === "activity") {
        // Replace this subagent's own activity part (scoped by _subagentId so
        // multiple concurrent/sequential tasks don't overwrite each other).
        let found = false;
        for (let i = parts.length - 1; i >= 0; i--) {
          if (
            parts[i].type === "info" &&
            parts[i]._subagent === "activity" &&
            parts[i]._subagentId === subagentId
          ) {
            parts[i] = {
              type: "info",
              text: t,
              _subagent: "activity",
              _subagentId: subagentId,
            } as const;
            found = true;
            break;
          }
        }
        if (!found) {
          const idx = findInsertAfter();
          parts.splice(idx, 0, {
            type: "info",
            text: t,
            _subagent: "activity",
            _subagentId: subagentId,
          } as const);
        }
      } else {
        // kind === 'finished': find/replace this subagent's own finished part, or
        // insert after the last part for this subagentId.
        let found = false;
        for (let i = parts.length - 1; i >= 0; i--) {
          if (
            parts[i].type === "info" &&
            parts[i]._subagent === "finished" &&
            parts[i]._subagentId === subagentId
          ) {
            parts[i] = {
              type: "info",
              text: t,
              _subagent: "finished",
              _subagentId: subagentId,
            } as const;
            found = true;
            break;
          }
        }
        if (!found) {
          const idx = findInsertAfter();
          parts.splice(idx, 0, {
            type: "info",
            text: t,
            _subagent: "finished",
            _subagentId: subagentId,
          } as const);
        }
      }
      return { ...m, parts: m.parts };
    });
  }

  function findSubagentInsertAfter(parts: any[], subagentId: string): number {
    for (let i = parts.length - 1; i >= 0; i--) {
      if (parts[i]?._subagentId === subagentId) return i + 1;
      if (parts[i]?.type === "tool_call" && parts[i]?.call?.subagentId === subagentId) return i + 1;
      if (parts[i]?.type === "tool_output" && parts[i]?.subagentId === subagentId) return i + 1;
      if (parts[i]?.type === "tool_result" && parts[i]?.result?.subagentId === subagentId)
        return i + 1;
    }
    return parts.length;
  }

  /**
   * Advance the spinner frame counter on all active (non-finished) subagent header parts.
   * Each call triggers a message update → layout rebuild → spinner animation.
   */
  function tickSubagentFrame(assistantId: string): void {
    updateAssistant(assistantId, (m) => {
      const parts = m.parts as any[];
      // Collect subagentIds that already have a 'finished' part
      const finishedIds = new Set<string>();
      for (const p of parts) {
        if (p?.type === "info" && p._subagent === "finished" && p._subagentId)
          finishedIds.add(p._subagentId);
      }
      // Tick all active (non-finished) headers
      for (let i = 0; i < parts.length; i++) {
        if (
          parts[i]?.type === "info" &&
          parts[i]._subagent === "header" &&
          !finishedIds.has(parts[i]._subagentId)
        ) {
          const frame = Number(parts[i]._frame ?? 0);
          parts[i] = { ...parts[i], _frame: frame + 1 };
        }
      }
      return { ...m, parts: m.parts };
    });
  }

  function appendAssistantLlmMeta(
    assistantId: string,
    info: Readonly<{
      durationMs: number;
      modelId?: string;
      toolCalls?: number;
      tokensUsed?: number;
      changedFiles?: readonly string[];
      changedFileDetails?: readonly LlmMetaChangedFile[];
      isFinalRound?: boolean;
    }>,
  ): void {
    const durationMs = Number(info?.durationMs);
    if (!Number.isFinite(durationMs) || durationMs < 0) return;
    const modelId = String(info?.modelId ?? "").trim() || undefined;
    const toolCalls = Number(info?.toolCalls);
    const tokensUsed = Number(info?.tokensUsed);
    const changedFileDetails = (() => {
      const raw = Array.isArray(info?.changedFileDetails) ? info.changedFileDetails : [];
      if (raw.length === 0) return [];
      const out: LlmMetaChangedFile[] = [];
      const seen = new Set<string>();
      for (const item of raw) {
        const path = String(item?.path ?? "").trim();
        if (!path || seen.has(path)) continue;
        seen.add(path);
        const added = Number(item?.added);
        const removed = Number(item?.removed);
        out.push({
          path,
          ...(Number.isFinite(added) && added >= 0 ? { added: Math.floor(added) } : {}),
          ...(Number.isFinite(removed) && removed >= 0 ? { removed: Math.floor(removed) } : {}),
        });
      }
      return out;
    })();
    const changedFiles = (() => {
      const raw = Array.isArray(info?.changedFiles) ? info.changedFiles : [];
      const source = raw.length > 0 ? raw : changedFileDetails.map((item) => item.path);
      if (source.length === 0) return [];
      const out: string[] = [];
      const seen = new Set<string>();
      for (const item of source) {
        const file = String(item ?? "").trim();
        if (!file) continue;
        if (seen.has(file)) continue;
        seen.add(file);
        out.push(file);
      }
      return out;
    })();
    const hasToolCalls = Number.isFinite(toolCalls) && toolCalls >= 0;
    const hasTokensUsed = Number.isFinite(tokensUsed) && tokensUsed >= 0;
    const hasChangedFiles = changedFiles.length > 0;
    const hasChangedFileDetails = changedFileDetails.length > 0;
    updateAssistant(assistantId, (m) => {
      if (!info?.isFinalRound) {
        // Suppress meta on tool-only rounds to avoid confusing mid-run summaries.
        let lastToolCallIndex = -1;
        for (let i = 0; i < m.parts.length; i++) {
          if (m.parts[i]?.type === "tool_call") lastToolCallIndex = i;
        }
        if (lastToolCallIndex >= 0) {
          let hasUserFacingAfterToolCall = false;
          for (let i = lastToolCallIndex + 1; i < m.parts.length; i++) {
            const part = m.parts[i];
            if (!part) continue;
            if (part.type === "markdown" && String(part.markdown ?? "").trim()) {
              hasUserFacingAfterToolCall = true;
              break;
            }
            if (part.type === "todo" || part.type === "plan" || part.type === "approve") {
              hasUserFacingAfterToolCall = true;
              break;
            }
          }
          if (!hasUserFacingAfterToolCall) return m;
        }
      }
      const parts = m.parts.slice();
      parts.push({
        type: "llm_meta",
        durationMs,
        ...(modelId ? { modelId } : {}),
        ...(hasToolCalls ? { toolCalls: Math.floor(toolCalls) } : {}),
        ...(hasTokensUsed ? { tokensUsed: Math.floor(tokensUsed) } : {}),
        ...(hasChangedFiles ? { changedFiles } : {}),
        ...(hasChangedFileDetails ? { changedFileDetails } : {}),
      } as const);
      return { ...m, parts };
    });
  }

  function updateAssistantLlmMeta(
    assistantId: string,
    info: Readonly<{
      durationMs?: number;
      modelId?: string;
      toolCalls?: number;
      tokensUsed?: number;
      changedFiles?: readonly string[];
      changedFileDetails?: readonly LlmMetaChangedFile[];
    }>,
  ): void {
    const durationMs = Number(info?.durationMs);
    const modelId = String(info?.modelId ?? "").trim() || undefined;
    const toolCalls = Number(info?.toolCalls);
    const tokensUsed = Number(info?.tokensUsed);
    const changedFileDetails = (() => {
      const raw = Array.isArray(info?.changedFileDetails) ? info.changedFileDetails : null;
      if (!raw) return null;
      const out: LlmMetaChangedFile[] = [];
      const seen = new Set<string>();
      for (const item of raw) {
        const path = String(item?.path ?? "").trim();
        if (!path || seen.has(path)) continue;
        seen.add(path);
        const added = Number(item?.added);
        const removed = Number(item?.removed);
        out.push({
          path,
          ...(Number.isFinite(added) && added >= 0 ? { added: Math.floor(added) } : {}),
          ...(Number.isFinite(removed) && removed >= 0 ? { removed: Math.floor(removed) } : {}),
        });
      }
      return out;
    })();
    const changedFiles = (() => {
      const raw = Array.isArray(info?.changedFiles) ? info.changedFiles : null;
      if (!raw) {
        if (!Array.isArray(changedFileDetails)) return null;
        return changedFileDetails.map((item) => item.path);
      }
      if (!raw) return null;
      const out: string[] = [];
      const seen = new Set<string>();
      for (const item of raw) {
        const file = String(item ?? "").trim();
        if (!file) continue;
        if (seen.has(file)) continue;
        seen.add(file);
        out.push(file);
      }
      return out;
    })();
    updateAssistant(assistantId, (m) => {
      const parts = m.parts.slice();
      for (let i = parts.length - 1; i >= 0; i--) {
        const p = parts[i];
        if (p.type !== "llm_meta") continue;
        const next: any = { ...p };
        let changed = false;
        if (Number.isFinite(durationMs) && durationMs >= 0) {
          next.durationMs = durationMs;
          changed = true;
        }
        if (modelId) {
          next.modelId = modelId;
          changed = true;
        }
        if (Number.isFinite(toolCalls) && toolCalls >= 0) {
          next.toolCalls = Math.floor(toolCalls);
          changed = true;
        }
        if (Number.isFinite(tokensUsed) && tokensUsed >= 0) {
          next.tokensUsed = Math.floor(tokensUsed);
          changed = true;
        }
        if (Array.isArray(changedFiles)) {
          if (changedFiles.length > 0) next.changedFiles = changedFiles;
          else delete next.changedFiles;
          changed = true;
        }
        if (Array.isArray(changedFileDetails)) {
          if (changedFileDetails.length > 0) next.changedFileDetails = changedFileDetails;
          else delete next.changedFileDetails;
          changed = true;
        }
        if (!changed) return m;
        parts[i] = next;
        return { ...m, parts };
      }
      return m;
    });
  }

  function upsertToolCallDelta(
    assistantId: string,
    callId: string,
    toolName: string | undefined,
    argsTextDelta: string | undefined,
    linesWritten?: number,
    meta?: Readonly<{ subagentId?: string }>,
  ): void {
    const idRaw = String(callId ?? "").trim();
    if (!idRaw) return;
    const name = String(toolName ?? "").trim() || "tool";
    const delta = String(argsTextDelta ?? "");
    const subagentId = String(meta?.subagentId ?? "").trim() || undefined;
    const t0 = perfStreamEnabled ? Date.now() : 0;
    updateAssistant(assistantId, (m) => {
      // Streaming optimization: mutate in-place to avoid allocating a new parts array per tool chunk.
      const parts = m.parts as any[];
      let idx = -1;
      for (let i = parts.length - 1; i >= 0; i--) {
        const p = parts[i];
        if (p?.type === "tool_call" && p?.call?.id === idRaw) {
          idx = i;
          break;
        }
      }
      if (idx >= 0) {
        const p = parts[idx] as Extract<(typeof m.parts)[number], { type: "tool_call" }>;
        const prevTail = String(p.call.argumentsText ?? "");
        const prevFullLen = Number((p.call as any).__argsTextFullLen ?? prevTail.length);
        const nextFullLen = prevFullLen + delta.length;
        const nextTailRaw = `${prevTail}${delta}`;
        const nextTail =
          nextTailRaw.length > streamToolArgsTailLimit
            ? nextTailRaw.slice(-streamToolArgsTailLimit)
            : nextTailRaw;
        const prevStartedAtMs = Number(p.call.startedAtMs ?? 0);
        const startedAtMs =
          Number.isFinite(prevStartedAtMs) && prevStartedAtMs > 0 ? prevStartedAtMs : Date.now();
        const nextCall: ToolCall = {
          ...p.call,
          name,
          ...(subagentId ? { subagentId } : {}),
          argumentsText: nextTail,
          startedAtMs,
          ...(typeof linesWritten === "number" ? { linesWritten } : null),
        };
        (nextCall as any).__argsTextFullLen = nextFullLen;
        (nextCall as any).__argsTextTailLimit = streamToolArgsTailLimit;
        parts[idx] = { type: "tool_call", call: nextCall } as const;
        return { ...m, parts: m.parts };
      }
      const firstTail =
        delta.length > streamToolArgsTailLimit ? delta.slice(-streamToolArgsTailLimit) : delta;
      const call: ToolCall = {
        id: idRaw,
        name,
        ...(subagentId ? { subagentId } : {}),
        arguments: {},
        argumentsText: firstTail,
        startedAtMs: Date.now(),
        ...(typeof linesWritten === "number" ? { linesWritten } : null),
      };
      (call as any).__argsTextFullLen = delta.length;
      (call as any).__argsTextTailLimit = streamToolArgsTailLimit;
      const next = {
        type: "tool_call",
        call,
        ...(subagentId ? { _subagentId: subagentId } : {}),
      } as const;
      if (subagentId) {
        const insertAt = findSubagentInsertAfter(parts, subagentId);
        parts.splice(insertAt, 0, next);
      } else {
        parts.push(next);
      }
      return { ...m, parts: m.parts };
    });
    if (perfStreamEnabled) {
      const ms = Date.now() - t0;
      const threshold = isOneShotEnabled() ? 5 : 40;
      if (ms >= threshold) {
        const now = Date.now();
        if (isOneShotEnabled() || now - lastStreamSlowLogAt >= 800) {
          lastStreamSlowLogAt = now;
          perfLogStream(
            `[STREAM_TC] ${ms}ms callId=${idRaw} tool=${name} deltaChars=${delta.length}`,
          );
        }
      }
    }
  }

  function upsertToolCallEnd(
    assistantId: string,
    callId: string,
    toolName: string,
    args: Record<string, unknown>,
    thoughtSignature?: string,
    meta?: Readonly<{ subagentId?: string }>,
  ): void {
    const idRaw = String(callId ?? "").trim();
    if (!idRaw) return;
    const subagentId = String(meta?.subagentId ?? "").trim() || undefined;
    updateAssistant(assistantId, (m) => {
      const parts = m.parts.slice();
      const idx = parts.findIndex((p) => p.type === "tool_call" && p.call.id === idRaw);
      const prevCall =
        idx >= 0 && parts[idx]?.type === "tool_call"
          ? ((parts[idx] as any).call as ToolCall)
          : undefined;
      const prevArgsText = prevCall?.argumentsText;
      const nextThoughtSignature = thoughtSignature || prevCall?.thoughtSignature;
      const prevLinesWritten =
        typeof (prevCall as any)?.linesWritten === "number"
          ? (prevCall as any).linesWritten
          : undefined;
      const call: ToolCall = {
        id: idRaw,
        name: toolName,
        ...(subagentId ? { subagentId } : {}),
        arguments: args,
        argumentsText: prevArgsText,
        ...(nextThoughtSignature ? { thoughtSignature: nextThoughtSignature } : {}),
        ...(typeof prevLinesWritten === "number" ? { linesWritten: prevLinesWritten } : null),
      };
      const nextPart = {
        type: "tool_call",
        call,
        ...(subagentId ? { _subagentId: subagentId } : {}),
      } as const;
      if (idx >= 0) parts[idx] = nextPart;
      else if (subagentId) {
        const insertAt = findSubagentInsertAfter(parts, subagentId);
        parts.splice(insertAt, 0, nextPart);
      } else parts.push(nextPart);
      return { ...m, parts };
    });
  }

  function upsertToolOutputStart(
    assistantId: string,
    toolCallId: string,
    toolName: string,
    meta?: Readonly<{ subagentId?: string }>,
  ): void {
    const idRaw = String(toolCallId ?? "").trim();
    if (!idRaw) return;
    const name = String(toolName ?? "").trim() || "tool";
    const subagentId = String(meta?.subagentId ?? "").trim() || undefined;
    const isExplore = name.toLowerCase() === "task";
    const tailLimit = isExplore
      ? Math.max(streamToolOutputTailLimit, 200_000)
      : streamToolOutputTailLimit;
    const tailLinesLimit = isExplore
      ? Math.max(streamToolOutputTailLinesLimit, 10_000)
      : streamToolOutputTailLinesLimit;
    updateAssistant(assistantId, (m) => {
      const parts = m.parts as any[];
      const idx = parts.findIndex((p) => p?.type === "tool_output" && p?.toolCallId === idRaw);
      if (idx >= 0) {
        const prev = parts[idx] as any;
        parts[idx] = {
          ...prev,
          type: "tool_output",
          toolCallId: idRaw,
          toolName: prev.toolName || name,
          ...(subagentId ? { subagentId } : {}),
          __tailLimit: tailLimit,
          __tailLinesLimit: tailLinesLimit,
        } as const;
        return { ...m, parts: m.parts };
      }
      const next = {
        type: "tool_output",
        toolCallId: idRaw,
        toolName: name,
        output: "",
        ...(subagentId ? { subagentId, _subagentId: subagentId } : {}),
        __fullLen: 0,
        __lineBreaks: 0,
        __tailLines: [""],
        __tailLimit: tailLimit,
        __tailLinesLimit: tailLinesLimit,
      } as const;
      const callIdx = parts.findIndex((p) => p?.type === "tool_call" && p?.call?.id === idRaw);
      if (callIdx >= 0) {
        let insertAt = callIdx + 1;
        while (
          insertAt < parts.length &&
          parts[insertAt]?.type === "approve" &&
          (parts[insertAt] as any)?.request?.toolCallId === idRaw
        ) {
          insertAt++;
        }
        parts.splice(insertAt, 0, next);
      } else if (subagentId) {
        const insertAt = findSubagentInsertAfter(parts, subagentId);
        parts.splice(insertAt, 0, next);
      } else {
        parts.push(next);
      }
      return { ...m, parts: m.parts };
    });
  }

  function appendToolOutputDelta(
    assistantId: string,
    toolCallId: string,
    toolName: string,
    delta: string,
    meta?: Readonly<{ subagentId?: string }>,
  ): void {
    const idRaw = String(toolCallId ?? "").trim();
    if (!idRaw) return;
    const d = String(delta ?? "").replace(/\r/g, "");
    if (!d) return;
    const name = String(toolName ?? "").trim() || "tool";
    const subagentId = String(meta?.subagentId ?? "").trim() || undefined;
    const isExplore = name.toLowerCase() === "task";
    const t0 = perfStreamEnabled ? Date.now() : 0;
    updateAssistant(assistantId, (m) => {
      // Streaming optimization: mutate in-place to avoid allocating a new parts array per chunk.
      const parts = m.parts as any[];
      const idx = parts.findIndex((p) => p?.type === "tool_output" && p?.toolCallId === idRaw);
      if (idx >= 0) {
        const prev = parts[idx] as any as Extract<(typeof parts)[number], { type: "tool_output" }>;
        const prevTail = String(prev.output ?? "");
        const tailLimit = (() => {
          const v = Number((prev as any).__tailLimit);
          if (Number.isFinite(v) && v > 0) return Math.floor(v);
          return isExplore
            ? Math.max(streamToolOutputTailLimit, 200_000)
            : streamToolOutputTailLimit;
        })();
        const tailLinesLimit = (() => {
          const v = Number((prev as any).__tailLinesLimit);
          if (Number.isFinite(v) && v > 0) return Math.floor(v);
          return isExplore
            ? Math.max(streamToolOutputTailLinesLimit, 10_000)
            : streamToolOutputTailLinesLimit;
        })();
        const nextTailRaw = `${prevTail}${d}`;
        const nextTail =
          nextTailRaw.length > tailLimit ? nextTailRaw.slice(-tailLimit) : nextTailRaw;

        const prevFullLen = Number((prev as any).__fullLen ?? prevTail.length);
        const prevLineBreaks = Number((prev as any).__lineBreaks ?? countNewlines(prevTail));
        const nextFullLen = prevFullLen + d.length;
        const nextLineBreaks = prevLineBreaks + countNewlines(d);

        const nextTailLines = computeTailLinesFromTailText(nextTail, tailLinesLimit);

        parts[idx] = {
          ...prev,
          type: "tool_output",
          toolCallId: idRaw,
          toolName: prev.toolName || name,
          ...(subagentId ? { subagentId } : {}),
          output: nextTail,
          __fullLen: nextFullLen,
          __lineBreaks: nextLineBreaks,
          __tailLines: nextTailLines,
          __tailLimit: tailLimit,
          __tailLinesLimit: tailLinesLimit,
        } as const;
        return { ...m, parts: m.parts };
      }
      const tailLimit = isExplore
        ? Math.max(streamToolOutputTailLimit, 200_000)
        : streamToolOutputTailLimit;
      const tailLinesLimit = isExplore
        ? Math.max(streamToolOutputTailLinesLimit, 10_000)
        : streamToolOutputTailLinesLimit;
      const firstTail = d.length > tailLimit ? d.slice(-tailLimit) : d;
      const firstTailLines = computeTailLinesFromTailText(firstTail, tailLinesLimit);
      const next = {
        type: "tool_output",
        toolCallId: idRaw,
        toolName: name,
        output: firstTail,
        ...(subagentId ? { subagentId, _subagentId: subagentId } : {}),
        __fullLen: d.length,
        __lineBreaks: countNewlines(d),
        __tailLines: firstTailLines,
        __tailLimit: tailLimit,
        __tailLinesLimit: tailLinesLimit,
      } as const;
      const callIdx = parts.findIndex((p) => p?.type === "tool_call" && p?.call?.id === idRaw);
      if (callIdx >= 0) {
        let insertAt = callIdx + 1;
        while (
          insertAt < parts.length &&
          parts[insertAt]?.type === "approve" &&
          (parts[insertAt] as any)?.request?.toolCallId === idRaw
        ) {
          insertAt++;
        }
        parts.splice(insertAt, 0, next);
      } else if (subagentId) {
        const insertAt = findSubagentInsertAfter(parts, subagentId);
        parts.splice(insertAt, 0, next);
      } else {
        parts.push(next);
      }
      return { ...m, parts: m.parts };
    });
    if (perfStreamEnabled) {
      const ms = Date.now() - t0;
      const threshold = isOneShotEnabled() ? 5 : 40;
      if (ms >= threshold) {
        const now = Date.now();
        if (isOneShotEnabled() || now - lastStreamSlowLogAt >= 800) {
          lastStreamSlowLogAt = now;
          perfLogStream(`[STREAM_TO] ${ms}ms callId=${idRaw} tool=${name} deltaChars=${d.length}`);
        }
      }
    }
  }

  function attachToolResultPart(
    assistantId: string,
    result: ToolResult,
    meta?: Readonly<{ subagentId?: string }>,
  ): void {
    const callId = String(result.toolCallId ?? "").trim();
    if (!callId) return;
    const subagentId = String(meta?.subagentId ?? result.subagentId ?? "").trim() || undefined;
    updateAssistant(assistantId, (m) => {
      const parts = m.parts.slice();
      const callIdx = parts.findIndex((p) => p.type === "tool_call" && p.call.id === callId);
      const withoutPrev = parts.filter(
        (p) => !(p.type === "tool_result" && p.result.toolCallId === callId),
      );
      const rebuilt = withoutPrev.slice();
      if (callIdx >= 0) {
        const rebuiltCallIdx = rebuilt.findIndex(
          (p) => p.type === "tool_call" && p.call.id === callId,
        );
        if (rebuiltCallIdx >= 0) {
          let insertAt = rebuiltCallIdx + 1;
          while (
            insertAt < rebuilt.length &&
            rebuilt[insertAt]?.type === "approve" &&
            (rebuilt[insertAt] as any)?.request?.toolCallId === callId
          ) {
            insertAt++;
          }
          while (
            insertAt < rebuilt.length &&
            rebuilt[insertAt]?.type === "tool_output" &&
            (rebuilt[insertAt] as any)?.toolCallId === callId
          )
            insertAt++;
          rebuilt.splice(insertAt, 0, {
            type: "tool_result",
            result: subagentId ? { ...result, subagentId } : result,
            ...(subagentId ? { _subagentId: subagentId } : {}),
          } as const);
        } else {
          rebuilt.push({
            type: "tool_result",
            result: subagentId ? { ...result, subagentId } : result,
            ...(subagentId ? { _subagentId: subagentId } : {}),
          } as const);
        }
      } else if (subagentId) {
        const insertAt = findSubagentInsertAfter(rebuilt as any[], subagentId);
        rebuilt.splice(insertAt, 0, {
          type: "tool_result",
          result: { ...result, subagentId },
          _subagentId: subagentId,
        } as const);
      } else {
        rebuilt.push({ type: "tool_result", result } as const);
      }
      return { ...m, parts: rebuilt };
    });
  }

  function insertApprovalPart(assistantId: string, request: ApprovalRequest): void {
    updateAssistant(assistantId, (m) => {
      const has = m.parts.some(
        (p) =>
          p.type === "approve" &&
          p.request.toolCallId === request.toolCallId &&
          p.request.status === "pending",
      );
      if (has) return m;

      const parts = m.parts.slice();
      const callIdx = parts.findIndex(
        (p) => p.type === "tool_call" && p.call.id === request.toolCallId,
      );
      if (callIdx >= 0) parts.splice(callIdx + 1, 0, { type: "approve", request } as const);
      else if (request.subagentId) {
        const insertAt = findSubagentInsertAfter(parts, request.subagentId);
        parts.splice(insertAt, 0, { type: "approve", request } as const);
      } else parts.push({ type: "approve", request } as const);
      return { ...m, parts };
    });
  }

  function toggleChecklist(messageId: string, partType: "todo" | "plan", itemId: string): void {
    const msg = state.messages.find((m) => m.id === messageId);
    if (!msg || msg.role !== "assistant") return;

    // Tool-derived TodoWrite checklists are read-only in the transcript UI.
    // (They reflect the tool result and should not be mutated by user clicks.)
    if (partType === "todo") {
      const block = msg.parts.find(
        (p): p is Extract<AssistantContentPart, { type: "todo" }> =>
          p.type === "todo" && p.items.some((it) => it.id === itemId),
      );
      const callId = String((block as any)?.toolCallId ?? "").trim();
      if (callId) {
        const toolCall = msg.parts.find(
          (p): p is Extract<AssistantContentPart, { type: "tool_call" }> =>
            p.type === "tool_call" && p.call.id === callId,
        );
        const name = String(toolCall?.call?.name ?? "")
          .trim()
          .toLowerCase();
        if (!name || name === "todowrite") return;
      }
    }

    const parts = msg.parts.map((p) => {
      if (p.type !== partType) return p;
      return {
        ...p,
        items: p.items.map((it) => {
          if (it.id !== itemId) return it;
          const done = !it.done;
          return { ...it, done, status: done ? "completed" : "pending" };
        }),
      };
    });
    replaceMessage(messageId, { ...msg, parts });
  }

  function toggleBlockCollapsed(
    messageId: string,
    partType: "todo" | "plan" | "thinking",
    partId: string,
  ): void {
    const normalizedPartId = String(partId ?? "").trim();
    if (!normalizedPartId) return;
    const msg = state.messages.find((m) => m.id === messageId);
    if (!msg || msg.role !== "assistant") return;
    const parts = msg.parts.map((p) => {
      if (p.type !== partType) return p;
      if (String(p.partId ?? "").trim() !== normalizedPartId) return p;
      return { ...p, collapsed: !p.collapsed };
    });
    replaceMessage(messageId, { ...msg, parts });
  }

  function revertFromMessage(messageId: string): number {
    const idx = state.messages.findIndex((m) => m.id === messageId);
    if (idx < 0) return 0;
    const removed = state.messages.splice(idx);
    state.revertedMessages = removed;
    setSessionHistoryDirty(true);
    rebuildUserHistory();
    hooks.onMessagesRemoved?.(removed);
    return removed.length;
  }

  function restoreRevertedMessages(): number {
    if (state.revertedMessages.length === 0) return 0;
    const count = state.revertedMessages.length;
    const restored = state.revertedMessages.slice();
    state.messages.push(...restored);
    state.revertedMessages = [];
    // A full restore brings the transcript back to its pre-revert shape.
    setSessionHistoryDirty(false);
    rebuildUserHistory();
    hooks.onMessagesRestored?.(restored);
    return count;
  }

  function clearRevertedMessages(): void {
    state.revertedMessages = [];
  }

  const hasRevertedMessages = computed(() => state.revertedMessages.length > 0);
  const revertedMessageCount = computed(() => state.revertedMessages.length);

  function upsertChecklistPartFromDimSdk(
    assistantId: string,
    kind: "todo" | "plan",
    toolCallId: string,
    structured: any,
  ): void {
    const todos = structured?.todos;
    if (!Array.isArray(todos)) return;

    const items = todos
      .map((t: any, i: number) => {
        const rawStatus = String(t?.status ?? "").trim() || "pending";
        const status =
          rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "pending"
            ? rawStatus
            : rawStatus;
        return {
          id: `${toolCallId}:${i}`,
          text: String(t?.content ?? "").trim(),
          done: status === "completed",
          status,
        };
      })
      .filter((it: any) => Boolean(it.text));

    if (items.length === 0) return;

    const title = kind === "plan" ? t("chatParts.plan") : t("chatParts.todo");
    const partId = createChecklistPartId(kind, toolCallId);
    updateAssistant(assistantId, (m) => {
      const parts = m.parts.slice();
      const idx = parts.findIndex(
        (p) => p.type === kind && String((p as any)?.partId ?? "").trim() === partId,
      );
      if (idx >= 0) {
        const prev = parts[idx] as any;
        parts[idx] = {
          type: kind,
          partId,
          title,
          collapsed: Boolean(prev?.collapsed),
          items,
          toolCallId,
        } as any;
        return { ...m, parts };
      }

      const nextPart = {
        type: kind,
        partId,
        title,
        collapsed: false,
        items,
        toolCallId,
      } as any;
      const callIdx = parts.findIndex(
        (p) => p?.type === "tool_call" && (p as any)?.call?.id === toolCallId,
      );
      if (callIdx >= 0) {
        let insertAt = callIdx + 1;
        while (
          insertAt < parts.length &&
          parts[insertAt]?.type === "approve" &&
          (parts[insertAt] as any)?.request?.toolCallId === toolCallId
        ) {
          insertAt++;
        }
        while (
          insertAt < parts.length &&
          parts[insertAt]?.type === "tool_output" &&
          (parts[insertAt] as any)?.toolCallId === toolCallId
        )
          insertAt++;
        while (
          insertAt < parts.length &&
          parts[insertAt]?.type === "tool_result" &&
          (parts[insertAt] as any)?.result?.toolCallId === toolCallId
        )
          insertAt++;
        parts.splice(insertAt, 0, nextPart);
        return { ...m, parts };
      }

      parts.push(nextPart);
      return { ...m, parts };
    });
  }

  // Ensure history is correct if messages were preloaded (e.g. resumed session).
  rebuildUserHistory();

  return {
    pushMessage,
    setSessionHistoryDirty,
    replaceMessage,
    updateAssistant,
    setAssistantStatus,
    setAssistantThinking,
    startNewThinkingBlock,
    endThinkingBlock,
    appendAssistantMarkdown,
    appendInfoPart,
    upsertSubagentInfo,
    tickSubagentFrame,
    appendAssistantLlmMeta,
    updateAssistantLlmMeta,
    upsertToolCallDelta,
    upsertToolCallEnd,
    upsertToolOutputStart,
    appendToolOutputDelta,
    attachToolResultPart,
    insertApprovalPart,
    toggleChecklist,
    toggleBlockCollapsed,
    revertFromMessage,
    restoreRevertedMessages,
    clearRevertedMessages,
    rebuildUserHistory,
    hasRevertedMessages,
    revertedMessageCount,
    upsertChecklistPartFromDimSdk,
  };
}
