Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/renderer/pane-store.ts

typescript797 行查看原文
import { type BackgroundTask, type ConversationSelection, type SubagentTask, toolFile } from "@zpi-dev/ui";
import {
  type FileLocation,
  type FileOpenOrigin,
  markdownHtmlBrowserUrl,
  resolveLinkTarget,
  type WebOpenOptions,
  webOpenTarget,
} from "@zpi-dev/ui/links";
import { create } from "zustand";
import type {
  BrowserState,
  FilePreview,
  PaneEvent,
  SessionRecord,
  TerminalProfile,
} from "../shared/bridge.ts";
import type { ReviewSource } from "../shared/review.ts";
import {
  accept,
  addConversationSelection,
  closeSideChat,
  focusComposer,
  loadDraft,
  refreshSuggestions,
  report,
  retainedSessionViews,
  useStore,
} from "./store.ts";
import { terminalTitle } from "./terminal-presentation.ts";
import { unwrap } from "./unwrap.ts";
import { userErrorMessage } from "./user-error.ts";
export type PaneTab = (
  | { id: string; type: "background-task"; title: string; sessionId: string; taskId: string }
  | {
      id: string;
      type: "side-chat";
      title: string;
      sessionId: string;
      childSessionId: string;
      ordinal: number;
    }
  | { id: string; type: "side-chat-loading"; title: string; sessionId: string; ordinal: number }
  | { id: string; type: "task-directory"; title: string; sessionId: string }
  | {
      id: string;
      type: "subagent";
      title: string;
      sessionId: string;
      childSessionId: string;
    }
  | {
      id: string;
      type: "changes";
      sessionId: string;
      runId: string | null;
      path?: string;
      toolCallId?: string;
      reviewSource?: ReviewSource;
      title: string;
      cwd?: string;
    }
  | { id: string; type: "terminal"; title: string; sessionId: string; cwd: string; profile?: TerminalProfile }
  | { id: string; type: "new-tab"; title: string; sessionId?: string }
  | {
      id: string;
      type: "file-manager";
      title: string;
      sessionId: string;
      cwd: string;
      workspaceName: string;
      preview?: FilePreview;
      revealPath?: string;
    }
  | { id: string; type: "file"; title: string; sessionId: string; preview: FilePreview; cwd?: string }
  | { id: string; type: "browser"; title: string; sessionId?: string; state: BrowserState; fileKey?: string }
) & { ownerSessionId?: string; openedAt?: number };
export type FilePaneTab = Extract<PaneTab, { type: "file-manager" | "file" | "changes" }>;
export interface PaneFullView {
  surface: "side-pane" | "conversation";
  floating: "collapsed" | "expanded" | "minimized";
}
interface PanePreference {
  open: boolean;
  active?: string;
  fullView?: PaneFullView | null;
}
interface PaneState {
  open: boolean;
  tabs: PaneTab[];
  recentClosed: { tab: PaneTab; closedAt: number }[];
  fullView: PaneFullView | null;
  active?: string;
  ratio: number;
  preferences: Map<string | undefined, PanePreference>;
  deletedSessionIds: Set<string>;
}
let savedRatio = 0.45;
try {
  const value = Number(localStorage.getItem("zpi.rightPaneRatio"));
  if (value >= 0.2 && value <= 0.7) savedRatio = value;
} catch {
  /* Optional UI preference. */
}
export const usePane = create<PaneState>(() => ({
  open: false,
  tabs: [],
  recentClosed: [],
  fullView: null,
  ratio: savedRatio,
  preferences: new Map(),
  deletedSessionIds: new Set(),
}));
export function useSideChatBlocked(parentId: string | undefined) {
  const childId = usePane((state) => {
    const tab = state.tabs.find(
      (tab) => tab.id === state.active && tab.type === "side-chat" && tab.sessionId === parentId,
    );
    return tab?.type === "side-chat" ? tab.childSessionId : undefined;
  });
  return useStore((state) => Boolean(state.views.get(childId ?? "")?.question));
}
export function canOpenSideChat(record: SessionRecord | undefined) {
  return Boolean(
    record && !record.draft && !record.parentSessionId && !(record.newWorktree && !record.worktree),
  );
}
export function visiblePaneTabs(sessionId: string | undefined, tabs = usePane.getState().tabs) {
  return tabs.filter((tab) => tabOwner(tab) === sessionId);
}
function paneOwner(id: string | undefined) {
  if (id === undefined) return;
  const record = useStore.getState().sessions.get(id);
  if (record) return record.parentSessionId ?? id;
  // Archived child records leave the recent list, but their retained panes can still navigate.
  return (
    usePane.getState().tabs.find((tab) => tab.sessionId === id && tab.ownerSessionId !== undefined)
      ?.ownerSessionId ?? id
  );
}
function tabOwner(tab: PaneTab) {
  return tab.ownerSessionId ?? paneOwner(tab.sessionId);
}
function ownerDeleted(id: string | undefined) {
  return id !== undefined && usePane.getState().deletedSessionIds.has(id);
}
function restorePane() {
  const sessionId = useStore.getState().selected;
  const state = usePane.getState();
  const tabs = visiblePaneTabs(sessionId, state.tabs);
  const saved = state.preferences.get(sessionId);
  usePane.setState({
    open: saved?.open ?? tabs.length > 0,
    fullView: (saved?.open ?? tabs.length > 0) ? (saved?.fullView ?? null) : null,
    active: tabs.find((tab) => tab.id === saved?.active)?.id ?? tabs.at(-1)?.id,
  });
}
export function setPaneOpen(open: boolean) {
  if (open && visiblePaneTabs(useStore.getState().selected).length === 0) {
    openNewPaneTab();
    return;
  }
  const state = usePane.getState();
  const preferences = new Map(state.preferences);
  preferences.set(useStore.getState().selected, {
    open,
    active: state.active,
    fullView: open ? state.fullView : null,
  });
  usePane.setState({ open, preferences, fullView: open ? state.fullView : null });
}
export function activatePaneTab(id: string) {
  const sessionId = useStore.getState().selected;
  if (!visiblePaneTabs(sessionId).some((tab) => tab.id === id)) return;
  const state = usePane.getState();
  const preferences = new Map(state.preferences);
  const fullView = state.fullView ? { ...state.fullView, surface: "side-pane" as const } : null;
  preferences.set(sessionId, { open: state.open, active: id, fullView });
  usePane.setState({ active: id, preferences, fullView });
}
export function reorderPaneTab(id: string, targetId: string) {
  const state = usePane.getState();
  const visible = visiblePaneTabs(useStore.getState().selected, state.tabs);
  const source = visible.find((tab) => tab.id === id);
  if (!source || id === targetId || !visible.some((tab) => tab.id === targetId)) return;
  const reordered = visible.filter((tab) => tab.id !== id);
  reordered.splice(
    visible.findIndex((tab) => tab.id === targetId),
    0,
    source,
  );
  const ids = new Set(visible.map((tab) => tab.id));
  let index = 0;
  usePane.setState({ tabs: state.tabs.map((tab) => (ids.has(tab.id) ? (reordered[index++] ?? tab) : tab)) });
}
export const terminalOutput = new Map<string, string>();
export const terminalExited = new Set<string>();
export const terminalListeners = new Map<string, (event: Extract<PaneEvent, { type: "terminal" }>) => void>();
export function listenPanes() {
  restorePane();
  const offSelection = useStore.subscribe((state, previous) => {
    if (state.sessions !== previous.sessions) {
      for (const tab of usePane.getState().tabs) {
        const parent = state.sessions.get(tabOwner(tab) ?? "");
        if (
          (!parent || parent.archivedAt != null) &&
          (tab.type === "side-chat" ||
            tab.type === "side-chat-loading" ||
            tab.type === "subagent" ||
            tab.type === "task-directory" ||
            tab.type === "background-task")
        )
          paneTask(closeTab(tab.id));
      }
    }
    if (state.selected !== previous.selected) restorePane();
  });
  const offEvents = window.zpi.onPaneEvent((event) => {
    if (event.type === "terminal") {
      terminalOutput.set(event.id, ((terminalOutput.get(event.id) ?? "") + event.data).slice(-524288));
      if (event.exited) terminalExited.add(event.id);
      terminalListeners.get(event.id)?.(event);
    } else {
      const current = usePane.getState();
      usePane.setState({
        tabs: current.tabs.map((tab) =>
          tab.type === "browser" && tab.id === event.state.id
            ? { ...tab, state: event.state, title: event.state.title || "浏览器" }
            : tab,
        ),
      });
    }
  });
  return () => {
    offSelection();
    offEvents();
  };
}
function showTab(tab: PaneTab) {
  const ownerSessionId = tabOwner(tab);
  if (ownerDeleted(ownerSessionId)) return;
  // Archival removes child records from the session list; retain their original owner.
  tab = { ...tab, ownerSessionId, openedAt: tab.openedAt ?? Date.now() };
  const current = usePane.getState();
  const preferences = new Map(current.preferences);
  const fullView = current.preferences.get(ownerSessionId)?.fullView;
  preferences.set(ownerSessionId, {
    open: true,
    active: tab.id,
    fullView: fullView ? { ...fullView, surface: "side-pane" } : null,
  });
  const launcher = current.tabs.find(
    (item) =>
      item.id === current.preferences.get(ownerSessionId)?.active &&
      item.type === "new-tab" &&
      tabOwner(item) === ownerSessionId,
  );
  const existing = current.tabs.some((item) => item.id === tab.id);
  const replaceLauncher = launcher && tab.type !== "new-tab";
  const tabs = replaceLauncher
    ? existing
      ? current.tabs
          .filter((item) => item.id !== launcher.id)
          .map((item) => (item.id === tab.id ? tab : item))
      : current.tabs.map((item) => (item.id === launcher.id ? tab : item))
    : existing
      ? current.tabs.map((item) => (item.id === tab.id ? tab : item))
      : [...current.tabs, tab];
  usePane.setState({ preferences, tabs });
  // 异步打开时冻结来源任务;迟到结果只更新来源任务,不能展开或激活当前任务的侧栏。
  if (ownerSessionId === useStore.getState().selected) restorePane();
}
const pendingSideChats = new Map<string, { tabId: string; promise: Promise<string | undefined> }>();
let sideChatRequest = 0;
export async function loadSideChat(id: string, current = () => true) {
  retainedSessionViews.add(id);
  try {
    // Draft readiness is required before mounting the editor; optional suggestions are not.
    const [snapshot, draft] = await Promise.allSettled([
      window.zpi.getSessionSnapshot(id).then(unwrap),
      loadDraft(id),
    ]);
    if (!current()) return;
    if (snapshot.status === "rejected") throw snapshot.reason;
    if (draft.status === "rejected") throw draft.reason;
    accept(snapshot.value);
    const retained = () => retainedSessionViews.has(id) && current();
    void refreshSuggestions(id, retained).catch((error) => {
      if (retained()) report(error);
    });
  } catch (error) {
    retainedSessionViews.delete(id);
    throw error;
  }
}
export async function openSideChat(parentId: string, reference?: ConversationSelection) {
  const focusOrigin = document.activeElement;
  const state = usePane.getState();
  const active = state.tabs.find(
    (tab) => tab.id === state.active && tab.type === "side-chat" && tab.sessionId === parentId,
  );
  let childId = reference && active?.type === "side-chat" ? active.childSessionId : undefined;
  if (childId && active) {
    showTab(active);
    const snapshot = await window.zpi.getSessionSnapshot(childId);
    if (
      !usePane
        .getState()
        .tabs.some(
          (tab) => tab.id === active.id && tab.type === "side-chat" && tab.childSessionId === childId,
        )
    )
      return;
    if (!snapshot.ok && snapshot.error.code === "not_found") {
      await closeTab(active.id);
      childId = undefined;
    } else {
      const view = unwrap(snapshot).view;
      if (view.question) throw new Error("辅助对话正在等待你处理请求");
    }
  }
  if (!childId) {
    let pending = pendingSideChats.get(parentId);
    if (!pending) {
      const used = new Set(
        visiblePaneTabs(parentId).flatMap((tab) =>
          tab.type === "side-chat" || tab.type === "side-chat-loading" ? [tab.ordinal] : [],
        ),
      );
      let ordinal = 1;
      while (used.has(ordinal)) ordinal++;
      const tabId = `side-chat:request:${++sideChatRequest}`;
      const tab = {
        id: tabId,
        type: "side-chat-loading" as const,
        title: `辅助对话 ${ordinal}`,
        ordinal,
        sessionId: parentId,
      };
      showTab(tab);
      const current = () => usePane.getState().tabs.some((item) => item.id === tabId);
      const promise = (async () => {
        let created: string | undefined;
        try {
          const record = unwrap(await window.zpi.createSideChat(parentId));
          created = record.id;
          if (current()) await loadSideChat(record.id, current);
          const parent = useStore.getState().sessions.get(parentId);
          if (!current() || !parent || parent.archivedAt != null || ownerDeleted(parentId)) {
            await closeSideChat(record.id);
            await closeTab(tabId);
            return;
          }
          // Replace in place: completion must not reopen a collapsed pane or change the active tab.
          usePane.setState((state) => ({
            tabs: state.tabs.map((item) =>
              item.id === tabId && item.type === "side-chat-loading"
                ? { ...item, type: "side-chat", childSessionId: record.id }
                : item,
            ),
          }));
          return record.id;
        } catch (error) {
          if (created) await closeSideChat(created).catch(report);
          const stillOpen = current();
          await closeTab(tabId);
          if (stillOpen) throw error;
        } finally {
          if (pendingSideChats.get(parentId)?.tabId === tabId) pendingSideChats.delete(parentId);
        }
      })();
      pending = { tabId, promise };
      pendingSideChats.set(parentId, pending);
    }
    childId = await pending.promise;
  }
  if (!childId) return;
  const pane = usePane.getState();
  const tab = pane.tabs.find((tab) => tab.type === "side-chat" && tab.childSessionId === childId);
  if (!tab) return;
  if (reference) addConversationSelection(reference, childId, false);
  if (pane.open && pane.active === tab.id && useStore.getState().selected === parentId)
    focusComposer(childId, {
      onlyIfFocusLost:
        document.activeElement !== focusOrigin ||
        Boolean(focusOrigin?.matches('input, textarea, [contenteditable="true"]')),
    });
}
export async function openSubagent(task: SubagentTask) {
  const retained = retainedSessionViews.has(task.childSessionId);
  retainedSessionViews.add(task.childSessionId);
  try {
    accept(unwrap(await window.zpi.getSessionSnapshot(task.childSessionId)));
    showTab({
      id: `subagent:${task.childSessionId}`,
      type: "subagent",
      title: task.description,
      sessionId: task.parentSessionId,
      childSessionId: task.childSessionId,
    });
  } catch (error) {
    if (!retained) retainedSessionViews.delete(task.childSessionId);
    throw error;
  }
}
export function openNewPaneTab() {
  showTab({
    id: `new-tab:${crypto.randomUUID()}`,
    type: "new-tab",
    title: "新标签页",
    sessionId: useStore.getState().selected,
  });
}
export async function openFileManager(sessionId: string, revealPath?: string) {
  const ownerSessionId = paneOwner(sessionId);
  const workspace = unwrap(await window.zpi.getWorkspaceInfo(sessionId));
  if (ownerDeleted(ownerSessionId)) return;
  showTab({
    id: `file-manager:${crypto.randomUUID()}`,
    type: "file-manager",
    title: "文件",
    sessionId,
    ownerSessionId,
    cwd: workspace.cwd,
    workspaceName: workspace.projectId
      ? workspace.projectName
      : workspace.cwd.split(/[\\/]/).at(-1) || "Workspace",
    revealPath,
  });
}
const fileManagerRequests = new Map<string, number>();
let fileManagerVersion = 0;
export async function navigateFileManager(tabId: string, path: string) {
  const tab = usePane.getState().tabs.find((tab) => tab.id === tabId);
  if (!tab || !["file-manager", "file", "changes"].includes(tab.type) || !tab.sessionId) return;
  if (tab.type === "file" && pendingFileReads)
    fileOpenVersions.set(`file:${tab.sessionId}:${tab.preview.path}`, ++fileOpenVersion);
  const version = ++fileManagerVersion;
  fileManagerRequests.set(tabId, version);
  try {
    const result = await window.zpi.readFilePreview(tab.sessionId, path);
    if (fileManagerRequests.get(tabId) !== version || ownerDeleted(tabOwner(tab))) return;
    const preview = unwrap(result);
    // Navigation updates this instance without activating a tab or changing task ownership.
    usePane.setState((state) => ({
      tabs: state.tabs.map((item) => {
        if (item.id !== tabId) return item;
        const title = preview.path.split(/[\\/]/).at(-1) || "文件";
        if (item.type === "file-manager") return { ...item, preview, title };
        if (item.type === "file" || item.type === "changes")
          return {
            id: item.id,
            type: "file",
            sessionId: item.sessionId,
            ownerSessionId: item.ownerSessionId,
            cwd: item.cwd,
            preview,
            title,
          };
        return item;
      }),
    }));
  } finally {
    if (fileManagerRequests.get(tabId) === version) fileManagerRequests.delete(tabId);
  }
}
export function openChanges(sessionId: string, runId: string | null, path?: string, toolCallId?: string) {
  path ||= undefined;
  toolCallId ||= undefined;
  // 文件名不能只用于标题:同轮的不同文件必须独立,旧内存页签按来源字段继续复用。
  const existing = usePane
    .getState()
    .tabs.find(
      (tab) =>
        tab.type === "changes" &&
        !tab.reviewSource &&
        tab.sessionId === sessionId &&
        tab.runId === runId &&
        (tab.path || undefined) === path &&
        (tab.toolCallId || undefined) === toolCallId,
    );
  const id =
    existing?.id ??
    availableTabId(`changes:${JSON.stringify([sessionId, runId, toolCallId ?? null, path ?? null])}`);
  if (existing) fileManagerRequests.delete(existing.id);
  showTab({
    id,
    type: "changes",
    sessionId,
    runId,
    path,
    toolCallId,
    cwd: useStore.getState().sessions.get(sessionId)?.cwd,
    title: path ? (path.split(/[\\/]/).at(-1) ?? "变更") : "变更",
  });
}
export function openReview(sessionId: string) {
  const existing = usePane
    .getState()
    .tabs.find((tab) => tab.type === "changes" && tab.sessionId === sessionId && tab.reviewSource);
  if (existing) {
    showTab(existing);
    return;
  }
  showTab({
    id: `review:${sessionId}`,
    type: "changes",
    sessionId,
    runId: null,
    reviewSource: "unstaged",
    title: "审查",
    cwd: useStore.getState().sessions.get(sessionId)?.cwd,
  });
}
export function selectReviewSource(id: string, reviewSource: ReviewSource) {
  usePane.setState((state) => ({
    tabs: state.tabs.map((tab) => (tab.id === id && tab.type === "changes" ? { ...tab, reviewSource } : tab)),
  }));
}
export async function openTerminal(sessionId: string) {
  const ownerSessionId = paneOwner(sessionId);
  const cwd = unwrap(await window.zpi.getWorkspaceInfo(sessionId)).cwd;
  if (ownerDeleted(ownerSessionId)) return;
  const created = unwrap(await window.zpi.createTerminal(sessionId));
  if (ownerDeleted(ownerSessionId)) {
    unwrap(await window.zpi.closeTerminal(created.id));
    terminalOutput.delete(created.id);
    return;
  }
  const title = terminalTitle(
    cwd,
    usePane
      .getState()
      .tabs.filter((tab) => tab.type === "terminal" && tabOwner(tab) === ownerSessionId)
      .map((tab) => tab.title),
  );
  showTab({
    type: "terminal",
    id: created.id,
    title,
    sessionId,
    ownerSessionId,
    cwd,
    profile: created.profile,
  });
}
export async function openBrowser(
  url = "",
  sessionId = useStore.getState().selected,
  ownerSessionId = paneOwner(sessionId),
) {
  if (ownerDeleted(ownerSessionId)) return;
  const created = unwrap(await window.zpi.createBrowser(url));
  if (ownerDeleted(ownerSessionId)) {
    unwrap(await window.zpi.closeBrowser(created.id));
    return;
  }
  showTab({ type: "browser", id: created.id, title: "浏览器", sessionId, ownerSessionId, state: created });
}
export async function openWebLink(
  url: string,
  options?: WebOpenOptions,
  sessionId = useStore.getState().selected,
) {
  if (webOpenTarget(url, options) === "app-browser") await openBrowser(url, sessionId);
  else unwrap(await window.zpi.openExternal(url));
}
const fileOpenVersions = new Map<string, number>();
let fileOpenVersion = 0;
let pendingFileReads = 0;
function availableTabId(preferred: string) {
  return usePane.getState().tabs.some((tab) => tab.id === preferred)
    ? `${preferred}:${crypto.randomUUID()}`
    : preferred;
}
export async function openFile(
  sessionId: string,
  path: string,
  location?: FileLocation,
  origin?: FileOpenOrigin,
) {
  const ownerSessionId = paneOwner(sessionId);
  const state = useStore.getState();
  const cwd = state.sessions.get(sessionId)?.cwd;
  const target = resolveLinkTarget(path, { cwd });
  // Streamed write/edit parameters are a preview, not a file that already exists on disk.
  for (const run of state.views.get(sessionId)?.runs ?? []) {
    if (run.status !== "running") continue;
    for (const block of run.orderedBlocks) {
      if (
        block.type !== "tool" ||
        !["write", "edit"].includes(block.name) ||
        !["preparing", "running"].includes(block.status)
      )
        continue;
      const file = toolFile(block);
      const pending = file && resolveLinkTarget(file.path, { cwd });
      if (
        file &&
        (file.path === path ||
          (target?.kind === "file" && pending?.kind === "file" && target.path === pending.path))
      ) {
        openChanges(sessionId, run.runId, file.path, block.toolCallId);
        return;
      }
    }
  }
  const requestKey = `file:${sessionId}:${target?.kind === "file" ? target.path : path}`;
  const version = ++fileOpenVersion;
  fileOpenVersions.set(requestKey, version);
  pendingFileReads++;
  try {
    const result = await window.zpi.readFilePreview(sessionId, path, location);
    if (ownerDeleted(ownerSessionId) || fileOpenVersions.get(requestKey) !== version) return;
    const preview = unwrap(result);
    const id = `file:${sessionId}:${preview.path}`;
    // 相对路径和符号链接可能指向同一标签;较旧的读取或关闭前的刷新不得重新提交。
    if ((fileOpenVersions.get(id) ?? 0) > version) return;
    fileOpenVersions.set(id, version);
    if (preview.kind === "directory") {
      unwrap(await window.zpi.fileAction(sessionId, preview.path, "reveal"));
      return;
    }
    const url = markdownHtmlBrowserUrl(
      preview.path,
      preview.location ?? location ?? (target?.kind === "file" ? target : undefined),
      origin,
    );
    if (url) {
      const existing = usePane
        .getState()
        .tabs.find(
          (tab): tab is Extract<PaneTab, { type: "browser" }> =>
            tab.type === "browser" && tab.sessionId === sessionId && tab.state.url === url,
        );
      if (existing) {
        showTab({ ...existing, fileKey: id });
        return;
      }
      const created = unwrap(await window.zpi.createBrowser(url));
      if (
        ownerDeleted(ownerSessionId) ||
        fileOpenVersions.get(id) !== version ||
        fileOpenVersions.get(requestKey) !== version
      ) {
        unwrap(await window.zpi.closeBrowser(created.id));
        return;
      }
      showTab({
        type: "browser",
        id: created.id,
        title: "浏览器",
        sessionId,
        ownerSessionId,
        state: created,
        fileKey: id,
      });
      return;
    }
    const existing = usePane
      .getState()
      .tabs.find(
        (tab) => tab.type === "file" && tab.sessionId === sessionId && tab.preview.path === preview.path,
      );
    if (existing) fileManagerRequests.delete(existing.id);
    showTab({
      id: existing?.id ?? availableTabId(id),
      type: "file",
      title: preview.path.split("/").at(-1) ?? preview.path,
      sessionId,
      ownerSessionId,
      preview,
      cwd,
    });
  } finally {
    // 仅在读取未结束时保留版本,避免已关闭文件永久占用状态。
    if (--pendingFileReads === 0) fileOpenVersions.clear();
  }
}
/** Only a successful explicit deletion proves that retained native panes must be closed. */
export async function deleteSessionWithPanes(sessionId: string) {
  unwrap(await window.zpi.deleteSession(sessionId));
  const state = usePane.getState();
  usePane.setState({ deletedSessionIds: new Set(state.deletedSessionIds).add(sessionId) });
  await Promise.all(
    state.tabs
      .filter((tab) => tabOwner(tab) === sessionId)
      .map((tab) =>
        closeTab(tab.id).catch((error) =>
          report(new Error(`任务已删除,但侧栏清理失败:${userErrorMessage(error)}`, { cause: error })),
        ),
      ),
  );
  const preferences = new Map(usePane.getState().preferences);
  preferences.delete(sessionId);
  usePane.setState({
    preferences,
    recentClosed: usePane.getState().recentClosed.filter(({ tab }) => tabOwner(tab) !== sessionId),
  });
}
export async function closeTab(id: string) {
  const state = usePane.getState(),
    tab = state.tabs.find((t) => t.id === id);
  if (!tab) return;
  fileManagerRequests.delete(id);
  if (tab.type === "side-chat-loading" && pendingSideChats.get(tab.sessionId)?.tabId === id)
    pendingSideChats.delete(tab.sessionId);
  if (pendingFileReads) {
    if (tab.type === "file")
      fileOpenVersions.set(`file:${tab.sessionId}:${tab.preview.path}`, ++fileOpenVersion);
    if (tab.type === "browser" && tab.fileKey) fileOpenVersions.set(tab.fileKey, ++fileOpenVersion);
  }
  if (tab.type === "terminal") {
    unwrap(await window.zpi.closeTerminal(id));
    terminalOutput.delete(id);
    terminalExited.delete(id);
  }
  if (tab.type === "browser") unwrap(await window.zpi.closeBrowser(id));
  if (tab.type === "subagent") retainedSessionViews.delete(tab.childSessionId);
  if (tab.type === "side-chat") {
    await closeSideChat(tab.childSessionId);
  }
  const current = usePane.getState();
  const tabs = current.tabs.filter((item) => item.id !== id);
  const preferences = new Map(current.preferences);
  const owner = tabOwner(tab);
  const saved = preferences.get(owner);
  if (saved) {
    const visible = visiblePaneTabs(owner, tabs);
    const previous = visiblePaneTabs(owner, current.tabs);
    const index = previous.findIndex((item) => item.id === id);
    preferences.set(owner, {
      open: saved.open && visible.length > 0,
      fullView: visible.length > 0 ? saved.fullView : null,
      active: saved.active === id ? visible[Math.min(index, visible.length - 1)]?.id : saved.active,
    });
  }
  const recentClosed =
    ownerDeleted(owner) || ["new-tab", "side-chat", "side-chat-loading"].includes(tab.type)
      ? current.recentClosed
      : [
          { tab, closedAt: Date.now() },
          ...current.recentClosed.filter((item) => item.tab.id !== tab.id),
        ].slice(0, 8);
  usePane.setState({ tabs, preferences, recentClosed });
  restorePane();
}
export function paneTask(task: Promise<unknown>) {
  void task.catch(report);
}

export function openTaskDirectory(sessionId: string) {
  showTab({ id: `tasks:${sessionId}`, type: "task-directory", title: "任务目录", sessionId });
}
export function openBackgroundTask(task: BackgroundTask) {
  showTab({
    id: task.taskId,
    type: "background-task",
    title: task.description,
    sessionId: task.sessionId,
    taskId: task.taskId,
  });
}

export function updatePaneFullView(fullView: PaneFullView | null) {
  const state = usePane.getState();
  const preferences = new Map(state.preferences);
  const owner = useStore.getState().selected;
  preferences.set(owner, { open: state.open, active: state.active, fullView });
  usePane.setState({ fullView, preferences });
}
export function togglePaneFullView() {
  const state = usePane.getState();
  if (!state.open) setPaneOpen(true);
  updatePaneFullView(state.fullView ? null : { surface: "side-pane", floating: "collapsed" });
}
export async function closePaneTabs(except?: string) {
  const tabs = visiblePaneTabs(useStore.getState().selected);
  for (const tab of tabs) if (tab.id !== except) await closeTab(tab.id);
}
export async function reopenPaneTab(id: string) {
  const item = usePane.getState().recentClosed.find((item) => item.tab.id === id);
  const owner = useStore.getState().selected;
  if (!item || tabOwner(item.tab) !== owner || ownerDeleted(owner)) return;
  const tab = item.tab;
  if (tab.type === "browser") await openBrowser(tab.state.url, tab.sessionId);
  else if (tab.type === "terminal") await openTerminal(tab.sessionId);
  else {
    if (tab.type === "subagent") {
      accept(unwrap(await window.zpi.getSessionSnapshot(tab.childSessionId)));
      retainedSessionViews.add(tab.childSessionId);
    }
    showTab({ ...tab, openedAt: Date.now() });
  }
  usePane.setState((state) => ({ recentClosed: state.recentClosed.filter((closed) => closed !== item) }));
}