Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/renderer/store.ts

typescript831 行查看原文
import { buildMentionMarkdown } from "@zpi-dev/coding-agent/input";
import type { DesktopEvent, DesktopEventEnvelope, InputSuggestion, SessionView } from "@zpi-dev/ui";
import {
  appendSelection,
  ComposerDraftStore,
  type ConversationSelection,
  emptySession,
  isNavigableRun,
  mergeHistory,
  mergeHistoryPages,
  reduceSession,
  sessionViewBytes,
} from "@zpi-dev/ui";
import { readReadingBookmark } from "@zpi-dev/ui/reading-position";
import { create } from "zustand";
import type { DesktopBridge, ProjectRecord, PublicSettings, SessionRecord } from "../shared/bridge.ts";
import {
  coalesceSessionEvents,
  eventBatchMaxChars,
  eventBatchMaxEvents,
  streamEventChars,
} from "../shared/event-batches.ts";
import { emptyTaskGroups, type TaskGroupState, taskGroupFor } from "../shared/task-groups.ts";
import { workspaceShortcutBlocked } from "./app-shortcuts.ts";
import { logRendererError } from "./error-log.ts";
import { recordEventBatch } from "./performance-diagnostics.ts";
import { previewLoader } from "./preview-loader.ts";
import { hasRestorableFocus, isVisibleShortcutTarget } from "./shortcut-context.ts";
import { readTaskPreferences, taskPreferencesKey } from "./sidebar-task-model.ts";
import { unwrap } from "./unwrap.ts";
import { userErrorMessage } from "./user-error.ts";

export { unwrap } from "./unwrap.ts";

declare global {
  interface Window {
    zpi: DesktopBridge;
  }
}
interface Store {
  taskGroups: TaskGroupState;
  taskGroupsHydrated: boolean;
  projects: ProjectRecord[];
  sessions: Map<string, SessionRecord>;
  views: Map<string, SessionView>;
  selected?: string;
  settings?: PublicSettings;
  error?: string;
  errorVersion: number;
  ready: boolean;
  historyCursors: Map<string, number | null>;
  historyLoading: Set<string>;
  suggestions: Map<string, InputSuggestion[]>;
  resourceDiagnostics: Map<string, { path: string; message: string }[]>;
}
export const useStore = create<Store>(() => ({
  taskGroups: emptyTaskGroups(),
  taskGroupsHydrated: false,
  projects: [],
  sessions: new Map(),
  views: new Map(),
  ready: false,
  errorVersion: 0,
  historyCursors: new Map(),
  historyLoading: new Set(),
  suggestions: new Map(),
  resourceDiagnostics: new Map(),
}));
export const drafts = new ComposerDraftStore();
const draftRevisions = new Map<string, number>();
const draftLoaded = new Set<string>();
const draftSaving = new Set<string>();
const draftSaveTimers = new Map<string, ReturnType<typeof setTimeout>>();
const draftSaveRequests = new Map<string, Promise<void>>();
export async function withdrawQueuedInput(id: string, itemId: string) {
  await saveDraft(id);
  const { item, draft } = unwrap(await window.zpi.editQueuedInput(id, itemId));
  // Main saved the withdrawn text before removing it from the durable queue.
  draftRevisions.set(id, draft.revision);
  return { ...draft, attachments: item.attachments, pending: 0 };
}
export function queueActions(id: string) {
  return {
    edit: (itemId: string) => withdrawQueuedInput(id, itemId),
    remove: (itemId: string) => window.zpi.removeQueuedInput(id, itemId).then(unwrap),
    sendNow: (itemId: string) => window.zpi.sendQueuedNow(id, itemId).then(unwrap),
    move: (itemId: string, beforeId: string | null) =>
      window.zpi.moveQueuedInput(id, itemId, beforeId).then(unwrap),
    resume: () => window.zpi.resumeInputQueue(id).then(unwrap),
  };
}
export type QuestionDraft = {
  requestId: string;
  index: number;
  answers: { selected: string[]; custom: string }[];
};
export const questionDrafts = new Map<string, QuestionDraft>();
function retainQuestionDraft(id: string, requestId?: string) {
  if (questionDrafts.get(id)?.requestId !== requestId) questionDrafts.delete(id);
}
drafts.subscribe((id) => {
  const draft = drafts.get(id);
  if (!draft || !draftLoaded.has(id)) return;
  const revision = (draftRevisions.get(id) ?? 0) + 1;
  draftRevisions.set(id, revision);
  draftSaving.add(id);
  clearTimeout(draftSaveTimers.get(id));
  draftSaveTimers.set(
    id,
    setTimeout(() => void saveDraft(id).catch(report), 200),
  );
});
function saveDraft(id: string): Promise<void> {
  if (!draftSaveTimers.has(id)) {
    const pending = draftSaveRequests.get(id);
    if (pending || !draftSaving.has(id)) return pending ?? Promise.resolve();
  }
  clearTimeout(draftSaveTimers.get(id));
  draftSaveTimers.delete(id);
  const draft = drafts.get(id);
  if (!draft) {
    draftSaving.delete(id);
    return Promise.resolve();
  }
  const revision = draftRevisions.get(id) ?? 0;
  const request = window.zpi
    .saveDraft(id, {
      text: draft.text,
      selections: draft.selections,
      fileReferences: draft.fileReferences,
      selection: draft.selection ?? [draft.text.length, draft.text.length],
      revision,
    })
    .then(unwrap)
    .then(() => {
      if (draftRevisions.get(id) === revision) draftSaving.delete(id);
    });
  draftSaveRequests.set(id, request);
  const cleanup = () => {
    if (draftSaveRequests.get(id) === request) draftSaveRequests.delete(id);
  };
  void request.then(cleanup, cleanup);
  return request;
}
async function flushDrafts(): Promise<void> {
  while (draftSaving.size) await Promise.all([...draftSaving].map(saveDraft));
}
export async function loadDraft(id: string): Promise<void> {
  if (draftLoaded.has(id)) return;
  const draft = unwrap(await window.zpi.getDraft(id));
  draftRevisions.set(id, draft.revision);
  if (!drafts.has(id)) drafts.set(id, { ...draft, attachments: [], pending: 0 });
  draftLoaded.add(id);
}
export const retainedSessionViews = new Set<string>();
function trimViews(keep?: string): void {
  const state = useStore.getState(),
    views = new Map(state.views);
  let size = [...views.values()].reduce((n, view) => n + sessionViewBytes(view), 0);
  for (const [id, view] of views) {
    if (views.size <= 8 && size <= 64 * 1024 * 1024) break;
    if (
      id === keep ||
      id === state.selected ||
      retainedSessionViews.has(id) ||
      state.sessions.get(id)?.status === "running" ||
      draftSaving.has(id) ||
      drafts.submitting.has(id)
    )
      continue;
    size -= sessionViewBytes(view);
    views.delete(id);
    historyGenerations.set(id, (historyGenerations.get(id) ?? 0) + 1);
    loadedSessions.delete(id);
  }
  useStore.setState({ views });
}
const loadedSessions = new Set<string>();
const historyGenerations = new Map<string, number>();
const historyRequests = new Map<string, object>();
function finishHistoryRequest(id: string, request: object): void {
  if (historyRequests.get(id) !== request) return;
  historyRequests.delete(id);
  const loading = new Set(useStore.getState().historyLoading);
  loading.delete(id);
  useStore.setState({ historyLoading: loading });
}
export function historyRevision(id: string): number {
  return historyGenerations.get(id) ?? 0;
}
const snapshotRequests = new Map<string, Promise<void>>();
const pendingSnapshots = new Map<string, DesktopEventEnvelope[]>();
export async function closeSideChat(id: string): Promise<void> {
  draftLoaded.delete(id);
  clearTimeout(draftSaveTimers.get(id));
  draftSaveTimers.delete(id);
  draftSaving.delete(id);
  await draftSaveRequests.get(id)?.catch(report);
  const result = await window.zpi.closeSideChat(id);
  if (!result.ok && result.error.code !== "not_found") {
    draftLoaded.add(id);
    unwrap(result);
  }
  historyGenerations.set(id, (historyGenerations.get(id) ?? 0) + 1);
  historyRequests.delete(id);
  drafts.delete(id);
  draftRevisions.delete(id);
  retainedSessionViews.delete(id);
  loadedSessions.delete(id);
  questionDrafts.delete(id);
  previewLoader.clear(id);
  const state = useStore.getState();
  const views = new Map(state.views),
    historyCursors = new Map(state.historyCursors),
    historyLoading = new Set(state.historyLoading),
    suggestions = new Map(state.suggestions),
    resourceDiagnostics = new Map(state.resourceDiagnostics);
  for (const cache of [views, historyCursors, historyLoading, suggestions, resourceDiagnostics])
    cache.delete(id);
  useStore.setState({ views, historyCursors, historyLoading, suggestions, resourceDiagnostics });
  trimViews(useStore.getState().selected);
}
let queue: DesktopEventEnvelope[] = [];
let queueChars = 0;
let frame = 0;
function reduceSessionRecord(
  record: SessionRecord,
  event: DesktopEvent,
  envelope?: DesktopEventEnvelope,
): SessionRecord {
  if (envelope?.activityAt !== undefined) {
    if ((record.activitySeq ?? 0) >= envelope.seq) return record;
    record = { ...record, updatedAt: envelope.activityAt, activitySeq: envelope.seq };
  }
  switch (event.type) {
    case "environment_changed":
      return { ...record, cwd: event.cwd, newWorktree: event.newWorktree, worktree: event.worktree };
    case "question_changed":
      return {
        ...record,
        pendingQuestion: event.question
          ? { id: event.question.id, autoResolution: event.question.autoResolution }
          : null,
      };
    case "session_changed":
      return { ...record, title: event.title };
    case "started":
      return { ...record, draft: false, status: "running", running: true, updatedAt: event.startedAt };
    case "settled":
      return {
        ...record,
        status: event.status,
        running: false,
        updatedAt: event.endedAt,
        unreadAt: event.unreadAt,
      };
    default:
      return record;
  }
}
function apply(events: DesktopEventEnvelope[]): void {
  const state = useStore.getState();
  let views = state.views,
    records = state.sessions;
  for (const e of coalesceSessionEvents(events, (id) =>
    pendingSnapshots.has(id) ? Number.POSITIVE_INFINITY : (state.views.get(id)?.seq ?? 0),
  )) {
    const pending = pendingSnapshots.get(e.sessionId);
    if (pending) {
      pending.push(e);
      continue;
    }
    const view = views.get(e.sessionId);
    if (!view || e.seq > view.seq) {
      if (e.event.type === "question_changed") retainQuestionDraft(e.sessionId, e.event.question?.id);
      if (e.event.type === "settled") questionDrafts.delete(e.sessionId);
      if (e.event.type === "history_reset") retainQuestionDraft(e.sessionId, e.event.view.question?.id);
    }
    if (view) {
      const next = reduceSession(view, e);
      if (next !== view) {
        if (e.event.type === "history_reset")
          historyGenerations.set(e.sessionId, (historyGenerations.get(e.sessionId) ?? 0) + 1);
        if (views === state.views) views = new Map(views);
        views.set(e.sessionId, next);
      }
    }
    const record = records.get(e.sessionId);
    if (record) {
      const next = reduceSessionRecord(record, e.event, e);
      if (next !== record) {
        if (records === state.sessions) records = new Map(records);
        records.set(e.sessionId, next);
      }
    }
  }
  if (views !== state.views || records !== state.sessions) useStore.setState({ views, sessions: records });
}
export type ReportScope = "window" | { input: string; revision?: number };
export function report(error: unknown, scope: ReportScope = "window"): void {
  logRendererError("reported", error);
  const message = userErrorMessage(error);
  // Input-scoped failures stay with the draft the user can still retry; everything else is window-scoped.
  if (scope !== "window") {
    const draft = drafts.get(scope.input);
    if (draft && (scope.revision === undefined || drafts.revision(scope.input) === scope.revision)) {
      drafts.set(scope.input, { ...draft, error: message });
      return;
    }
    if (scope.revision !== undefined) {
      // A late failure must not label a draft the user already changed.
      useStore.setState((state) => ({
        error: `上一次发送失败:${message}`,
        errorVersion: state.errorVersion + 1,
      }));
      return;
    }
  }
  useStore.setState((state) => ({ error: message, errorVersion: state.errorVersion + 1 }));
}
export function subscribeEvents(): () => void {
  const flush = () => void flushDrafts().catch(report);
  const offBeforeQuit = window.zpi.onBeforeQuit(flushDrafts);
  window.addEventListener("beforeunload", flush);
  const flushEvents = () => {
    if (frame) cancelAnimationFrame(frame);
    frame = 0;
    const batch = queue;
    queue = [];
    queueChars = 0;
    const startedAt = performance.now();
    try {
      apply(batch);
    } finally {
      recordEventBatch(performance.now() - startedAt, batch.length);
    }
  };
  const visibility = () => {
    if (document.visibilityState === "hidden") {
      flushEvents();
      flush();
    }
  };
  document.addEventListener("visibilitychange", visibility);
  const off = window.zpi.onEvents((events) => {
    for (const e of events) {
      if (e.event.type === "history_reset") previewLoader.clear(e.sessionId);
      const chars = streamEventChars(e);
      if (queue.length && (queue.length >= eventBatchMaxEvents || queueChars + chars > eventBatchMaxChars))
        flushEvents();
      queue.push(e);
      queueChars += chars;
      if (queue.length >= eventBatchMaxEvents || queueChars >= eventBatchMaxChars) flushEvents();
    }
    if (document.visibilityState === "hidden") flushEvents();
    else if (queue.length && !frame) frame = requestAnimationFrame(flushEvents);
  });
  return () => {
    flushEvents();
    flush();
    offBeforeQuit();
    window.removeEventListener("beforeunload", flush);
    document.removeEventListener("visibilitychange", visibility);
    off();
  };
}
export async function refreshSuggestions(id: string, current = () => true): Promise<void> {
  const [commands, catalog, custom] = await Promise.all([
    window.zpi.listCommands(id).then(unwrap),
    window.zpi.listSessionSkills(id).then(unwrap),
    window.zpi.listSessionCustomCommands(id).then(unwrap),
  ]);
  if (!current()) return;
  const state = useStore.getState();
  const resourceDiagnostics = new Map(state.resourceDiagnostics),
    suggestions = new Map(state.suggestions);
  resourceDiagnostics.set(id, [
    ...catalog.diagnostics,
    ...custom.diagnostics.map((item) => ({ path: item.path ?? custom.directoryPath, message: item.message })),
  ]);
  suggestions.set(id, [
    ...commands.map((c) => ({ ...c, insert: `/${c.name} `, group: "命令" as const })),
    ...custom.commands
      .filter(
        (command) =>
          command.enabled &&
          !command.disableNonInteractive &&
          /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(command.name.slice(1)) &&
          !commands.some((builtin) => builtin.name.toLowerCase() === command.name.slice(1).toLowerCase()),
      )
      .map((command) => ({
        name: command.name.slice(1),
        description: command.description ?? "",
        argumentHint: command.argumentHint,
        customCommand: true,
        insert: `${command.name} `,
        group: "命令" as const,
      })),
    ...catalog.skills.map((s) => ({
      name: s.name,
      description: `${s.source === "project" ? "工作区" : s.source === "user" ? "用户" : "附加"} · ${s.description}`,
      insert: `${buildMentionMarkdown(`$${s.name}`, s.path)} `,
      group: "Skill" as const,
    })),
    ...(custom.subagents ?? []).map((agent) => ({
      name: agent.name,
      description: agent.description,
      insert: `@${agent.name} `,
      group: "子智能体" as const,
    })),
  ]);
  useStore.setState({ resourceDiagnostics, suggestions });
}
let appliedTaskGroupRequest = 0;
export async function refresh(): Promise<void> {
  const groupRequest = appliedTaskGroupRequest;
  const [projects, records, settings, taskGroups] = await Promise.all([
    window.zpi.listProjects().then(unwrap),
    window.zpi.listRecentSessions().then(unwrap),
    window.zpi.getSettings().then(unwrap),
    window.zpi.getTaskGroups().then(unwrap),
  ]);
  const parentIds = new Set(records.map((record) => record.id));
  records.push(
    ...[...useStore.getState().sessions.values()].filter(
      (record) => record.parentSessionId && parentIds.has(record.parentSessionId),
    ),
  );
  useStore.setState({
    taskGroups: groupRequest === appliedTaskGroupRequest ? taskGroups : useStore.getState().taskGroups,
    taskGroupsHydrated: true,
    projects,
    sessions: new Map(
      records.map((record) => {
        const current = useStore.getState().sessions.get(record.id);
        return [
          record.id,
          current && (current.activitySeq ?? 0) > (record.activitySeq ?? 0)
            ? {
                ...record,
                updatedAt: current.updatedAt,
                activitySeq: current.activitySeq,
                status: current.status,
                running: current.running,
              }
            : record,
        ];
      }),
    ),
    settings,
  });
}
export function accept(snapshot: import("../shared/bridge.ts").SessionSnapshot): void {
  const state = useStore.getState();
  const current = state.views.get(snapshot.session.id);
  if (!current || current.seq <= snapshot.seq) {
    retainQuestionDraft(snapshot.session.id, snapshot.view.question?.id);
    const views = new Map(state.views),
      sessions = new Map(state.sessions),
      cursors = new Map(state.historyCursors);
    views.set(snapshot.session.id, current ? mergeHistory(current, snapshot.view) : snapshot.view);
    sessions.set(snapshot.session.id, snapshot.session);
    if (!cursors.has(snapshot.session.id)) cursors.set(snapshot.session.id, snapshot.historyCursor ?? null);
    useStore.setState({ views, sessions, historyCursors: cursors });
    trimViews(snapshot.session.id);
  }
}
const readingSessions = new Set<string>();
export async function markSessionRead(id: string): Promise<void> {
  const unreadAt = useStore.getState().sessions.get(id)?.unreadAt;
  if (unreadAt === undefined || readingSessions.has(id)) return;
  readingSessions.add(id);
  try {
    const active = unwrap(await window.zpi.activateSession(id));
    const sessions = new Map(useStore.getState().sessions);
    const record = sessions.get(id);
    // A later completion must retain its own unread indicator.
    if (record?.unreadAt === unreadAt) {
      sessions.set(id, { ...record, unreadAt: active.unreadAt });
      useStore.setState({ sessions });
    }
  } finally {
    readingSessions.delete(id);
  }
}
let selectionGeneration = 0;
export async function selectSession(id: string): Promise<void> {
  return selectSessionForNavigation(id, ++selectionGeneration);
}
async function selectSessionForNavigation(id: string, generation: number): Promise<void> {
  if (generation !== selectionGeneration) return;
  const previous = useStore.getState().selected;
  if (previous && previous !== id) await saveDraft(previous);
  // 保存草稿期间用户可能已重新选择任务,迟到的导航不能覆盖最新意图。
  if (generation !== selectionGeneration) return;
  localStorage.setItem("zpi.selectedSession", id);
  useStore.setState({ selected: id });
  const unreadAt = useStore.getState().sessions.get(id)?.unreadAt;
  const active = unwrap(await window.zpi.activateSession(id));
  const sessions = new Map(useStore.getState().sessions);
  const record = sessions.get(id);
  if (record && record.unreadAt === unreadAt) {
    sessions.set(id, { ...record, unreadAt: active.unreadAt });
    useStore.setState({ sessions });
  }
  await loadDraft(id);
  if (loadedSessions.has(id)) {
    await refreshSuggestions(id);
    return;
  }
  const existing = snapshotRequests.get(id);
  if (existing) return existing;
  pendingSnapshots.set(id, []);
  const request = (async () => {
    try {
      const snapshot = unwrap(await window.zpi.getSessionSnapshot(id));
      let view = snapshot.view,
        cursor = snapshot.historyCursor ?? null;
      try {
        const reading = readReadingBookmark(localStorage.getItem(`zpi.reading.${id}`));
        while (
          reading?.following === false &&
          cursor !== null &&
          (reading.runId
            ? !view.runs.some((run) => run.runId === reading.runId)
            : reading.cursor !== undefined && cursor > reading.cursor)
        ) {
          const page = unwrap(await window.zpi.getHistoryPage(id, cursor));
          view = mergeHistory(page.view, view);
          cursor = page.cursor;
        }
      } catch (error) {
        report(error);
      }

      const queued = queue.filter((e) => e.sessionId === id);
      queue = queue.filter((e) => e.sessionId !== id);
      queueChars = queue.reduce((chars, event) => chars + streamEventChars(event), 0);
      const buffered = [...(pendingSnapshots.get(id) ?? []), ...queued].sort((a, b) => a.seq - b.seq);
      for (const event of coalesceSessionEvents(buffered, () => snapshot.seq))
        if (event.seq > snapshot.seq) view = reduceSession(view, event);
      pendingSnapshots.delete(id);
      loadedSessions.add(id);
      const state = useStore.getState();
      const views = new Map(state.views),
        historyCursors = new Map(state.historyCursors),
        sessions = new Map(state.sessions);
      // 权威快照替换视图时,旧生命周期的分页响应必须失效。
      historyGenerations.set(id, (historyGenerations.get(id) ?? 0) + 1);
      views.delete(id);
      views.set(id, view);
      historyCursors.set(id, cursor);
      let record = snapshot.session;
      for (const e of buffered) if (e.seq > snapshot.seq) record = reduceSessionRecord(record, e.event, e);
      sessions.set(id, record);
      retainQuestionDraft(id, view.question?.id);
      useStore.setState({ views, sessions, historyCursors });
      trimViews(id);
    } catch (error) {
      const buffered = [
        ...(pendingSnapshots.get(id) ?? []),
        ...queue.filter((event) => event.sessionId === id),
      ];
      queue = queue.filter((event) => event.sessionId !== id);
      queueChars = queue.reduce((chars, event) => chars + streamEventChars(event), 0);
      pendingSnapshots.delete(id);
      if (!useStore.getState().views.has(id)) {
        const state = useStore.getState();
        useStore.setState({
          views: new Map(state.views).set(id, emptySession(id, state.sessions.get(id)?.title)),
        });
      }
      apply(buffered.sort((a, b) => a.seq - b.seq));
      throw error;
    } finally {
      pendingSnapshots.delete(id);
      snapshotRequests.delete(id);
    }
  })();
  snapshotRequests.set(id, request);
  await request;
  await refreshSuggestions(id);
}
export async function loadEarlier(id: string): Promise<void> {
  const state = useStore.getState(),
    cursor = state.historyCursors.get(id);
  if (cursor == null || state.historyLoading.has(id)) return;
  const generation = historyGenerations.get(id) ?? 0;
  const request = {};
  historyRequests.set(id, request);
  useStore.setState({ historyLoading: new Set(state.historyLoading).add(id) });
  try {
    const page = unwrap(await window.zpi.getHistoryPage(id, cursor));
    const current = useStore.getState(),
      view = current.views.get(id);
    // 编辑/重试会替换历史;旧分页响应不能把已移除的内容重新合并回来。
    // 流式事件只增加 seq,不改变历史代次,因此仍可正常合并分页。
    if (
      !view ||
      (historyGenerations.get(id) ?? 0) !== generation ||
      historyRequests.get(id) !== request ||
      current.historyCursors.get(id) !== cursor
    )
      return;
    const views = new Map(current.views),
      cursors = new Map(current.historyCursors);
    views.set(id, mergeHistory(page.view, view));
    cursors.set(id, page.cursor);
    useStore.setState({ views, historyCursors: cursors });
  } finally {
    finishHistoryRequest(id, request);
  }
}
/** Load a complete navigation directory without publishing partial pages. */
export async function loadAllEarlier(id: string): Promise<"loaded" | "stale" | "not-enough"> {
  const initial = useStore.getState();
  const initialCursor = initial.historyCursors.get(id);
  if (initialCursor == null || initial.historyLoading.has(id) || !initial.views.has(id)) return "stale";
  const generation = historyGenerations.get(id) ?? 0;
  const request = {};
  historyRequests.set(id, request);
  const stillCurrent = () => {
    const state = useStore.getState();
    return (
      state.views.has(id) &&
      historyRequests.get(id) === request &&
      (historyGenerations.get(id) ?? 0) === generation &&
      state.historyCursors.get(id) === initialCursor
    );
  };
  useStore.setState({ historyLoading: new Set(initial.historyLoading).add(id) });
  try {
    const pages: SessionView[] = [];
    let cursor: number | null = initialCursor;
    while (cursor !== null) {
      const before: number = cursor;
      const page = unwrap(await window.zpi.getHistoryPage(id, before));
      if (!stillCurrent()) return "stale";
      if (page.cursor !== null && page.cursor >= before) throw new Error("历史分页游标未推进");
      pages.push(page.view);
      cursor = page.cursor;
    }
    if (!stillCurrent()) return "stale";
    const state = useStore.getState();
    const current = state.views.get(id);
    if (!current) return "stale";
    const view = mergeHistoryPages([...pages.reverse(), current]);
    const count = view.runs.reduce(
      (count, run) => count + Number(isNavigableRun(run)) + (run.guides?.length ?? 0),
      0,
    );
    // A partial leading run needs its earlier pages even if the rail will remain hidden.
    if (count < 2 && !current.runs[0]?.historyContinuation) return "not-enough";
    const views = new Map(state.views);
    const cursors = new Map(state.historyCursors);
    views.set(id, view);
    cursors.set(id, null);
    useStore.setState({ views, historyCursors: cursors });
    return "loaded";
  } finally {
    finishHistoryRequest(id, request);
  }
}
const openingDrafts = new Map<string, Promise<SessionRecord>>();
export function focusComposer(sessionId: string, options: { onlyIfFocusLost?: boolean } = {}): void {
  requestAnimationFrame(() => {
    const state = useStore.getState();
    const record = state.sessions.get(sessionId);
    const parentId = record?.parentSessionId;
    if (
      !record ||
      state.selected !== (parentId ?? sessionId) ||
      document.visibilityState === "hidden" ||
      workspaceShortcutBlocked("focus-composer") ||
      (options.onlyIfFocusLost && hasRestorableFocus())
    )
      return;
    const editor = document.querySelector<HTMLElement>(
      parentId
        ? `[data-side-chat-session="${sessionId}"] .mention-editor[contenteditable=true]`
        : "main .composer-container .mention-editor[contenteditable=true]",
    );
    if (editor?.isConnected && isVisibleShortcutTarget(editor)) editor.focus();
  });
}
export async function newSession(
  projectId: string | null = useStore.getState().sessions.get(useStore.getState().selected ?? "")
    ?.projectId ?? null,
  groupId?: string | null,
): Promise<string | undefined> {
  // 创建去重与导航意图分开;A → B → A 的最后一次点击不能被 A 的在途资源读取吞掉。
  const generation = ++selectionGeneration;
  if (groupId === undefined) {
    let grouped = false;
    try {
      grouped = readTaskPreferences(localStorage.getItem(taskPreferencesKey)).organizeBy === "grouped";
    } catch {}
    const active = useStore.getState().sessions.get(useStore.getState().selected ?? "");
    groupId =
      grouped && active && active.pinnedAt == null && active.archivedAt == null && !active.parentSessionId
        ? taskGroupFor(useStore.getState().taskGroups, active.id)
        : null;
  }
  const targetGroup = groupId;
  const key = `${projectId ?? "_unassigned"}:${targetGroup ?? "root"}`;
  let opening = openingDrafts.get(key);
  if (!opening) {
    opening = (async () => {
      const draft = [...useStore.getState().sessions.values()]
        .filter(
          (record) =>
            record.draft &&
            !record.parentSessionId &&
            record.projectId === projectId &&
            record.archivedAt == null &&
            taskGroupFor(useStore.getState().taskGroups, record.id) === targetGroup,
        )
        .sort((a, b) => b.updatedAt - a.updatedAt)[0];
      const record = draft ?? unwrap(await window.zpi.createSession(projectId, targetGroup));
      if (!draft) {
        await refresh();
      }
      return record;
    })();
    openingDrafts.set(key, opening);
    const cleanup = () => {
      if (openingDrafts.get(key) === opening) openingDrafts.delete(key);
    };
    void opening.then(cleanup, cleanup);
  }
  const record = await opening;
  if (generation !== selectionGeneration) return;
  await selectSessionForNavigation(record.id, generation);
  if (generation !== selectionGeneration || useStore.getState().selected !== record.id) return;
  focusComposer(record.id);
  return record.id;
}
export async function archiveSession(id: string): Promise<void> {
  unwrap(await window.zpi.archiveSession(id));
  await refresh();
  if (useStore.getState().selected === id) {
    localStorage.removeItem("zpi.selectedSession");
    useStore.setState({ selected: undefined });
    const next = [...useStore.getState().sessions.values()].find((r) => !r.diagnostic);
    if (next) await selectSession(next.id);
  }
}
export async function initialize(): Promise<void> {
  try {
    await refresh();
    const records = [...useStore.getState().sessions.values()].filter((r) => !r.diagnostic);
    const saved = localStorage.getItem("zpi.selectedSession");
    const first = records.find((r) => r.id === saved) ?? records[0];
    if (first) await selectSession(first.id);
    else await newSession();
  } catch (error) {
    report(error);
  } finally {
    useStore.setState({ ready: true });
  }
}

export function addConversationSelection(
  reference: ConversationSelection,
  sessionId = useStore.getState().selected,
  focus = true,
): void {
  if (!sessionId) return;
  const draft = drafts.get(sessionId);
  if (!draft) return;
  try {
    drafts.set(sessionId, {
      ...draft,
      selections: appendSelection(draft.selections ?? [], reference),
      error: undefined,
    });
    if (focus) focusComposer(sessionId);
  } catch (error) {
    drafts.set(sessionId, { ...draft, error: userErrorMessage(error) });
  }
}
export function resetSession(snapshot: import("../shared/bridge.ts").SessionSnapshot): void {
  historyGenerations.set(snapshot.session.id, (historyGenerations.get(snapshot.session.id) ?? 0) + 1);
  previewLoader.clear(snapshot.session.id);
  const state = useStore.getState();
  const views = new Map(state.views),
    sessions = new Map(state.sessions),
    cursors = new Map(state.historyCursors);
  const current = views.get(snapshot.session.id);
  // Newer events already passed history_reset in sequence; keep their streamed content.
  if (!current || current.seq <= snapshot.seq) {
    retainQuestionDraft(snapshot.session.id, snapshot.view.question?.id);
    views.set(snapshot.session.id, snapshot.view);
    sessions.set(snapshot.session.id, snapshot.session);
  }
  cursors.set(snapshot.session.id, snapshot.historyCursor ?? null);
  useStore.setState({ views, sessions, historyCursors: cursors });
}

let taskGroupRequest = 0;
export async function changeTaskGroups(
  command: Parameters<DesktopBridge["updateTaskGroups"]>[0],
): Promise<TaskGroupState> {
  const request = ++taskGroupRequest;
  const taskGroups = unwrap(await window.zpi.updateTaskGroups(command));
  if (request > appliedTaskGroupRequest) {
    // 失败写入不能废弃首屏读取;只有成功落地的新状态才阻止旧刷新或旧命令回包覆盖。
    appliedTaskGroupRequest = request;
    useStore.setState({ taskGroups });
  }
  return taskGroups;
}

export async function prepareDiscardDraft(id: string): Promise<void> {
  if (!useStore.getState().sessions.get(id)?.draft) throw new Error("只能关闭未发送的新任务");
  await saveDraft(id);
}
export function forgetDiscardedDraft(id: string): void {
  clearTimeout(draftSaveTimers.get(id));
  draftSaveTimers.delete(id);
  draftSaving.delete(id);
  draftLoaded.delete(id);
  draftRevisions.delete(id);
  drafts.delete(id);
  loadedSessions.delete(id);
  if (useStore.getState().selected === id) {
    useStore.setState({ selected: undefined });
    localStorage.removeItem("zpi.selectedSession");
  }
}