859 lines · 33.6 KB · TypeScript
RawDownload
/** * Thread-scoped right-panel surface state. * * This is intentionally a shallow workspace model: it owns an ordered set of * surface descriptors and the active surface, while each feature continues to * own its durable resource state. Browser surfaces point at preview tab ids, * terminal surfaces point at terminal session ids, file surfaces point at * workspace paths, and diff/files remain singleton surfaces. */import { scopedThreadKey } from "@t3tools/client-runtime/environment";import type { ChatFileAttachment, ScopedThreadRef } from "@t3tools/contracts";import { create } from "zustand";import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage";import type { ThreadPanelPresentation } from "./rightPanelLayout"; export const RIGHT_PANEL_KINDS = [  "diff",  "files",  "file",  "preview",  "terminal",  "pull-request",  "agents",] as const;export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; export type RightPanelSurface =  | { id: `browser:${string}`; kind: "preview"; resourceId: string }  | { id: "browser:new"; kind: "preview"; resourceId: null }  | {      id: `terminal:${string}`;      kind: "terminal";      resourceId: string;      terminalIds: string[];      activeTerminalId: string;      splitDirection?: "horizontal" | "vertical";    }  | { id: "diff"; kind: "diff" }  | { id: "files"; kind: "files" }  | {      id: `file:${string}` | `attachment:${string}`;      kind: "file";      /** Workspace-relative, or absolute for a host file outside the workspace. */      relativePath: string;      revealLine: number | null;      revealRequestId: number;      /** Present when the file lives in the thread's attachment store rather          than at a workspace or host path. */      attachment?: ChatFileAttachment;    }  | {      /**       * A change request opened beside a thread or in the pull-request list's shared panel.       * The reference lives in the id so several pull requests can remain open as peer tabs.       */      id: `pull-request:${string}`;      kind: "pull-request";      /**       * Which server the change request was read from. The list spans every connected one, so       * two of them can hold the same project id; a panel beside a thread leaves this out and       * takes the environment from its own ref.       */      environmentId?: string;      projectId: string;      repository: string;      number: number;    }  | { id: "agents"; kind: "agents" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";// v9 removed the "plan" surface kind (plans render inline in the transcript).// v10 keys pull-request surfaces by reference instead of a singleton tab.// v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh.const RIGHT_PANEL_STORAGE_VERSION = 11; /** * The pull-request list's shared panel (see PULL_REQUESTS_PANEL_ID in the route) is session * state: reopening the app should show the list, not last session's tabs and detail fetches. */const isPullRequestsPanelKey = (threadKey: string) => threadKey.endsWith(":pull-requests-panel"); export interface ThreadRightPanelState {  isOpen: boolean;  activeSurfaceId: string | null;  surfaces: RightPanelSurface[];} export interface ThreadPanelVisibility {  inlineOpen: boolean;  popoverOpen: boolean;} interface RightPanelStoreState {  byThreadKey: Record<string, ThreadRightPanelState>;  threadPanelVisibilityByThreadKey: Record<string, ThreadPanelVisibility>;  open: (    ref: ScopedThreadRef,    kind: Exclude<RightPanelKind, "file" | "terminal" | "pull-request">,  ) => void;  openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;  openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void;  openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void;  openPullRequest: (    ref: ScopedThreadRef,    target: { environmentId?: string; projectId: string; repository: string; number: number },  ) => void;  openTerminal: (ref: ScopedThreadRef, terminalId: string) => void;  splitTerminal: (    ref: ScopedThreadRef,    surfaceId: string,    terminalId: string,    direction?: "horizontal" | "vertical",  ) => void;  activateTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void;  closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void;  activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void;  closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void;  closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void;  closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void;  closeAllSurfaces: (ref: ScopedThreadRef) => void;  reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void;  reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void;  show: (ref: ScopedThreadRef) => void;  close: (ref: ScopedThreadRef) => void;  toggleVisibility: (ref: ScopedThreadRef) => void;  toggle: (    ref: ScopedThreadRef,    kind: Exclude<RightPanelKind, "file" | "terminal" | "pull-request">,  ) => void;  setThreadPanelOpen: (    ref: ScopedThreadRef,    presentation: ThreadPanelPresentation,    open: boolean,  ) => void;  toggleThreadPanel: (ref: ScopedThreadRef, presentation: ThreadPanelPresentation) => void;  removeThread: (ref: ScopedThreadRef) => void;} const EMPTY_THREAD_STATE: ThreadRightPanelState = {  isOpen: false,  activeSurfaceId: null,  surfaces: [],}; const DEFAULT_THREAD_PANEL_VISIBILITY: ThreadPanelVisibility = {  inlineOpen: true,  popoverOpen: false,}; const singletonSurface = (  kind: Exclude<RightPanelKind, "file" | "preview" | "terminal" | "pull-request">,): RightPanelSurface => {  switch (kind) {    case "diff":      return { id: "diff", kind };    case "files":      return { id: "files", kind };    case "agents":      return { id: "agents", kind };  }}; const browserSurface = (tabId: string | null): RightPanelSurface =>  tabId    ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId }    : { id: "browser:new", kind: "preview", resourceId: null }; const fileSurface = (  relativePath: string,  revealLine: number | null,  revealRequestId: number,): RightPanelSurface => ({  id: `file:${relativePath}`,  kind: "file",  relativePath,  revealLine,  revealRequestId,}); const attachmentSurface = (attachment: ChatFileAttachment): RightPanelSurface => ({  id: `attachment:${attachment.id}`,  kind: "file",  relativePath: attachment.name,  revealLine: null,  revealRequestId: 0,  attachment,}); const terminalSurface = (terminalId: string): RightPanelSurface => ({  id: `terminal:${terminalId}`,  kind: "terminal",  resourceId: terminalId,  terminalIds: [terminalId],  activeTerminalId: terminalId,}); export type PullRequestSurface = Extract<RightPanelSurface, { kind: "pull-request" }>; export function pullRequestSurfaceId(target: {  environmentId?: string;  projectId: string;  repository: string;  number: number;}): PullRequestSurface["id"] {  // The environment leads the id where there is one, so the same change request read from two  // servers is two tabs rather than one tab that changes its mind about which server it is on.  const scope =    target.environmentId === undefined ? "" : `${encodeURIComponent(target.environmentId)}:`;  return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${encodeURIComponent(target.repository)}:${target.number}`;} export function pullRequestSurface(target: {  environmentId?: string;  projectId: string;  repository: string;  number: number;}): PullRequestSurface {  return {    id: pullRequestSurfaceId(target),    kind: "pull-request",    ...(target.environmentId === undefined ? {} : { environmentId: target.environmentId }),    projectId: target.projectId,    repository: target.repository,    number: target.number,  };} const upsertSurface = (  current: ThreadRightPanelState,  surface: RightPanelSurface,  activate = true,): ThreadRightPanelState => ({  isOpen: true,  surfaces: current.surfaces.some((entry) => entry.id === surface.id)    ? current.surfaces    : [...current.surfaces, surface],  activeSurfaceId: activate ? surface.id : current.activeSurfaceId,}); const updateThreadStateMap = (  byThreadKey: Record<string, ThreadRightPanelState>,  threadKey: string,  updater: (current: ThreadRightPanelState) => ThreadRightPanelState,): Record<string, ThreadRightPanelState> => {  const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;  const next = updater(current);  if (!next.isOpen && next.activeSurfaceId === null && next.surfaces.length === 0) {    if (!(threadKey in byThreadKey)) return byThreadKey;    const { [threadKey]: _removed, ...rest } = byThreadKey;    return rest;  }  if (next === current) return byThreadKey;  return { ...byThreadKey, [threadKey]: next };}; const updateThreadPanelVisibilityMap = (  byThreadKey: Record<string, ThreadPanelVisibility>,  threadKey: string,  updater: (current: ThreadPanelVisibility) => ThreadPanelVisibility,): Record<string, ThreadPanelVisibility> => {  const current = byThreadKey[threadKey] ?? DEFAULT_THREAD_PANEL_VISIBILITY;  const next = updater(current);  if (next.inlineOpen && !next.popoverOpen) {    if (!(threadKey in byThreadKey)) return byThreadKey;    const { [threadKey]: _removed, ...rest } = byThreadKey;    return rest;  }  if (next === current) return byThreadKey;  return { ...byThreadKey, [threadKey]: next };}; type RightPanelStoreData = Pick<  RightPanelStoreState,  "byThreadKey" | "threadPanelVisibilityByThreadKey">; /** * Applies a real right-panel mutation and its thread-panel transition atomically. * This is the only path real panel actions use, so visibility never has to be * reconciled after the fact in React. */const updateThread = (  state: RightPanelStoreData,  ref: ScopedThreadRef,  updater: (current: ThreadRightPanelState) => ThreadRightPanelState,): RightPanelStoreData => {  const threadKey = scopedThreadKey(ref);  const current = state.byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;  const byThreadKey = updateThreadStateMap(state.byThreadKey, threadKey, updater);  const next = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;  let threadPanelVisibilityByThreadKey = state.threadPanelVisibilityByThreadKey;   if (!current.isOpen && next.isOpen) {    threadPanelVisibilityByThreadKey = updateThreadPanelVisibilityMap(      threadPanelVisibilityByThreadKey,      threadKey,      (visibility) => (visibility.popoverOpen ? { ...visibility, popoverOpen: false } : visibility),    );  } else if (current.isOpen && !next.isOpen) {    threadPanelVisibilityByThreadKey = updateThreadPanelVisibilityMap(      threadPanelVisibilityByThreadKey,      threadKey,      (visibility) =>        visibility.popoverOpen && !visibility.inlineOpen          ? { ...visibility, inlineOpen: true }          : visibility,    );  }   return { byThreadKey, threadPanelVisibilityByThreadKey };}; function normalizeRevealLine(line: number | undefined): number | null {  if (line === undefined || !Number.isFinite(line)) return null;  return Math.max(1, Math.trunc(line));} export function migratePersistedRightPanelState(persistedState: unknown): {  byThreadKey: Record<string, ThreadRightPanelState>;  threadPanelVisibilityByThreadKey: Record<string, ThreadPanelVisibility>;} {  if (!persistedState || typeof persistedState !== "object") {    return { byThreadKey: {}, threadPanelVisibilityByThreadKey: {} };  }  const byThreadKey =    "byThreadKey" in persistedState &&    persistedState.byThreadKey &&    typeof persistedState.byThreadKey === "object"      ? Object.fromEntries(          Object.entries(persistedState.byThreadKey as Record<string, ThreadRightPanelState>)            .filter(([threadKey]) => !isPullRequestsPanelKey(threadKey))            .map(([threadKey, threadState]) => {              const validThreadState =                threadState && typeof threadState === "object" ? threadState : null;              const surfaces = Array.isArray(validThreadState?.surfaces)                ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => {                    // Dropped surface kind: plans now render inline in the                    // transcript (v9).                    if ((surface as { kind?: string }).kind === "plan") return [];                    if (surface.kind === "file") {                      const revealLine =                        typeof surface.revealLine === "number" &&                        Number.isFinite(surface.revealLine)                          ? Math.max(1, Math.trunc(surface.revealLine))                          : null;                      const revealRequestId =                        typeof surface.revealRequestId === "number" &&                        Number.isSafeInteger(surface.revealRequestId) &&                        surface.revealRequestId >= 0                          ? surface.revealRequestId                          : 0;                      return [{ ...surface, revealLine, revealRequestId }];                    }                    if (surface.kind === "pull-request") {                      if (                        typeof surface.projectId !== "string" ||                        typeof surface.repository !== "string" ||                        typeof surface.number !== "number" ||                        !Number.isSafeInteger(surface.number) ||                        surface.number < 1                      ) {                        return [];                      }                      const { environmentId, ...rest } = surface;                      // Anything else stored under that name is not an environment.                      return [                        pullRequestSurface({                          ...rest,                          ...(typeof environmentId === "string" ? { environmentId } : {}),                        }),                      ];                    }                    if (surface.kind !== "terminal") return [surface];                    if (                      !("resourceId" in surface) ||                      typeof surface.resourceId !== "string" ||                      surface.id !== `terminal:${surface.resourceId}`                    ) {                      return [];                    }                    const terminalIds =                      "terminalIds" in surface && Array.isArray(surface.terminalIds)                        ? [                            ...new Set(                              surface.terminalIds.filter(                                (terminalId): terminalId is string =>                                  typeof terminalId === "string",                              ),                            ),                          ]                        : [surface.resourceId];                    const activeTerminalId =                      "activeTerminalId" in surface &&                      typeof surface.activeTerminalId === "string" &&                      terminalIds.includes(surface.activeTerminalId)                        ? surface.activeTerminalId                        : (terminalIds[0] ?? surface.resourceId);                    return [                      {                        ...surface,                        terminalIds: terminalIds.length > 0 ? terminalIds : [surface.resourceId],                        activeTerminalId,                      },                    ];                  })                : [];              const rawActiveSurfaceId = validThreadState?.activeSurfaceId;              const persistedActiveSurfaceId = surfaces.some(                (surface) => surface.id === rawActiveSurfaceId,              )                ? (rawActiveSurfaceId ?? null)                : rawActiveSurfaceId === "pull-request"                  ? (surfaces.find((surface) => surface.kind === "pull-request")?.id ?? null)                  : null;              // A migration that dropped every surface (e.g. plan-only panels              // in v9) must not reopen an empty panel.              const isOpen =                surfaces.length > 0 &&                (typeof validThreadState?.isOpen === "boolean"                  ? validThreadState.isOpen                  : persistedActiveSurfaceId !== null);              // An open panel needs an active surface: if migration dropped              // the persisted one (e.g. plan was active), fall back to the              // first survivor instead of rendering an open empty panel.              const activeSurfaceId =                persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null);              return [threadKey, { isOpen, surfaces, activeSurfaceId }];            }),        )      : {};  const threadPanelVisibilityByThreadKey =    "threadPanelVisibilityByThreadKey" in persistedState &&    persistedState.threadPanelVisibilityByThreadKey &&    typeof persistedState.threadPanelVisibilityByThreadKey === "object"      ? Object.fromEntries(          Object.entries(            persistedState.threadPanelVisibilityByThreadKey as Record<string, unknown>,          ).flatMap(([threadKey, value]) => {            if (!value || typeof value !== "object" || !("inlineOpen" in value)) return [];            return value.inlineOpen === false              ? [[threadKey, { inlineOpen: false, popoverOpen: false }]]              : [];          }),        )      : {};  return { byThreadKey, threadPanelVisibilityByThreadKey };} export const useRightPanelStore = create<RightPanelStoreState>()(  persist(    (set) => ({      byThreadKey: {},      threadPanelVisibilityByThreadKey: {},      open: (ref, kind) =>        set((state) =>          updateThread(state, ref, (current) => {            if (kind === "preview") {              const existing = current.surfaces.find((surface) => surface.kind === "preview");              return upsertSurface(current, existing ?? browserSurface(null));            }            return upsertSurface(current, singletonSurface(kind));          }),        ),      openBrowser: (ref, tabId) =>        set((state) =>          updateThread(state, ref, (current) => {            const surface = browserSurface(tabId);            const withoutPlaceholder = tabId              ? current.surfaces.filter((entry) => entry.id !== "browser:new")              : current.surfaces;            return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface);          }),        ),      openPullRequest: (ref, target) =>        set((state) =>          updateThread(state, ref, (current) => upsertSurface(current, pullRequestSurface(target))),        ),      openFile: (ref, relativePath, line) =>        set((state) =>          updateThread(state, ref, (current) => {            const withoutStandaloneExplorer = current.surfaces.filter(              (surface) => surface.kind !== "files",            );            const surfaceId = `file:${relativePath}` as const;            const existing = withoutStandaloneExplorer.find(              (surface): surface is Extract<RightPanelSurface, { kind: "file" }> =>                surface.id === surfaceId && surface.kind === "file",            );            const surface = fileSurface(              relativePath,              normalizeRevealLine(line),              (existing?.revealRequestId ?? 0) + 1,            );            return {              isOpen: true,              activeSurfaceId: surface.id,              surfaces: existing                ? withoutStandaloneExplorer.map((entry) =>

Lines 1–500 of 859.