953 lines · 31.1 KB · TypeScript
RawDownload
import {  type Dispatch,  type ReactNode,  createContext,  createElement,  useContext,  useReducer,} from "react"; import {  type CoreReadModelSnapshot,  type CoreThreadMessageView,  type CoreViewDelta,  type ProviderSession,  normalizeProjectScripts,} from "@t3tools/contracts";import { resolveModelSlug } from "./model-logic";import {  type ChatAttachment,  DEFAULT_THREAD_TERMINAL_HEIGHT,  DEFAULT_THREAD_TERMINAL_ID,  DEFAULT_RUNTIME_MODE,  MAX_THREAD_TERMINAL_COUNT,  type ProjectScript,  type Project,  type RuntimeMode,  type Thread,  type ThreadTerminalGroup,} from "./types"; function normalizeScriptIcon(icon: string | null | undefined): ProjectScript["icon"] {  switch (icon) {    case "play":    case "test":    case "lint":    case "configure":    case "build":    case "debug":      return icon;    default:      return "play";  }} // ── Actions ────────────────────────────────────────────────────────── type Action =  | { type: "ADD_PROJECT"; project: Project }  | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] }  | { type: "SYNC_PROJECTS"; projects: Project[] }  | { type: "SET_THREADS_HYDRATED"; hydrated: boolean }  | { type: "APPLY_CORE_SNAPSHOT"; snapshot: CoreReadModelSnapshot }  | { type: "APPLY_CORE_DELTA"; delta: CoreViewDelta }  | { type: "TOGGLE_PROJECT"; projectId: string }  | { type: "DELETE_PROJECT"; projectId: string }  | { type: "ADD_THREAD"; thread: Thread }  | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }  | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }  | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number }  | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string }  | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string }  | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }  | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string }  | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }  | {      type: "PUSH_USER_MESSAGE";      threadId: string;      id: string;      text: string;      attachments?: ChatAttachment[];    }  | { type: "SET_ERROR"; threadId: string; error: string | null }  | { type: "SET_THREAD_TITLE"; threadId: string; title: string }  | { type: "SET_THREAD_MODEL"; threadId: string; model: string }  | {      type: "REVERT_TO_CHECKPOINT";      threadId: string;      sessionId: string;      threadRuntimeId: string;      turnCount: number;      messageCount: number;    }  | {      type: "SET_THREAD_TURN_CHECKPOINT_COUNTS";      threadId: string;      checkpointTurnCountByTurnId: Record<string, number>;    }  | {      type: "SET_THREAD_BRANCH";      threadId: string;      branch: string | null;      worktreePath: string | null;    }  | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string }  | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }  | { type: "DELETE_THREAD"; threadId: string }; // ── State ──────────────────────────────────────────────────────────── export interface AppState {  projects: Project[];  threads: Thread[];  threadsHydrated: boolean;  runtimeMode: RuntimeMode;} const initialState: AppState = {  projects: [],  threads: [],  threadsHydrated: false,  runtimeMode: DEFAULT_RUNTIME_MODE,}; // ── Helpers ────────────────────────────────────────────────────────── function readPersistedState(): AppState {  return initialState;} function mapCoreMessageToChatMessage(message: CoreThreadMessageView): Thread["messages"][number] {  return {    id: message.id,    role: message.role,    text: message.text,    createdAt: message.createdAt,    streaming: message.streaming,  };} function mapCoreScriptsToProjectScripts(  scripts: CoreReadModelSnapshot["projects"][number]["scripts"],): ProjectScript[] {  return scripts.map((script) => {    const icon = normalizeScriptIcon(script.icon ?? undefined);    return {      id: script.id,      name: script.name,      command: script.command,      icon,      runOnWorktreeCreate: script.runOnWorktreeCreate ?? false,    };  });} function mapSnapshotToState(snapshot: CoreReadModelSnapshot, previous: AppState): AppState {  const projects: Project[] = snapshot.projects.map((project) => ({    id: project.id,    name: project.name,    cwd: project.cwd,    model: project.model,    expanded: project.expanded,    scripts: mapCoreScriptsToProjectScripts(project.scripts),  }));   const threads: Thread[] = snapshot.threads.map((thread) => {    const existing = previous.threads.find((entry) => entry.id === thread.id);    const next: Thread = {      id: thread.id,      codexThreadId: thread.id,      projectId: thread.projectId,      title: thread.title,      model: thread.model,      terminalOpen: existing?.terminalOpen ?? false,      terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,      terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID],      runningTerminalIds: existing?.runningTerminalIds ?? [],      activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID,      terminalGroups: existing?.terminalGroups ?? [        {          id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,          terminalIds: [DEFAULT_THREAD_TERMINAL_ID],        },      ],      activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,      session: existing?.session ?? null,      messages: thread.messages.map(mapCoreMessageToChatMessage),      events: existing?.events ?? [],      error: existing?.error ?? null,      createdAt: thread.createdAt,      latestTurnId: existing?.latestTurnId,      latestTurnStartedAt: existing?.latestTurnStartedAt,      latestTurnCompletedAt: existing?.latestTurnCompletedAt,      latestTurnDurationMs: existing?.latestTurnDurationMs,      lastVisitedAt: existing?.lastVisitedAt,      branch: thread.branch,      worktreePath: thread.worktreePath,      turnDiffSummaries: existing?.turnDiffSummaries ?? [],    };    return normalizeThreadTerminals(next);  });   return {    ...previous,    projects,    threads,    threadsHydrated: true,  };} function updateThread(  threads: Thread[],  threadId: string,  updater: (t: Thread) => Thread,): Thread[] {  return threads.map((t) => (t.id === threadId ? updater(t) : t));} function normalizeTerminalIds(terminalIds: string[]): string[] {  const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);  const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT);  if (unique.length > 0) {    return unique;  }  return [DEFAULT_THREAD_TERMINAL_ID];} function normalizeRunningTerminalIds(  runningTerminalIds: string[],  terminalIds: string[],): string[] {  if (runningTerminalIds.length === 0) return [];  const validTerminalIdSet = new Set(terminalIds);  return [...new Set(runningTerminalIds)]    .map((id) => id.trim())    .filter((id) => id.length > 0 && validTerminalIdSet.has(id));}  function normalizeTerminalGroupIds(terminalIds: string[]): string[] {  return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];} function fallbackGroupId(terminalId: string): string {  return `group-${terminalId}`;} function assignUniqueGroupId(groupId: string, usedGroupIds: Set<string>): string {  if (!usedGroupIds.has(groupId)) {    usedGroupIds.add(groupId);    return groupId;  }  let suffix = 2;  while (usedGroupIds.has(`${groupId}-${suffix}`)) {    suffix += 1;  }  const uniqueGroupId = `${groupId}-${suffix}`;  usedGroupIds.add(uniqueGroupId);  return uniqueGroupId;} function normalizeTerminalGroups(thread: Thread, terminalIds: string[]): ThreadTerminalGroup[] {  const validTerminalIdSet = new Set(terminalIds);  const assignedTerminalIds = new Set<string>();  const usedGroupIds = new Set<string>();  const groups: ThreadTerminalGroup[] = [];   for (const group of thread.terminalGroups) {    const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => {      if (!validTerminalIdSet.has(terminalId)) return false;      if (assignedTerminalIds.has(terminalId)) return false;      return true;    });    if (groupTerminalIds.length === 0) continue;    for (const terminalId of groupTerminalIds) {      assignedTerminalIds.add(terminalId);    }    const baseGroupId =      group.id.trim().length > 0        ? group.id.trim()        : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);    groups.push({      id: assignUniqueGroupId(baseGroupId, usedGroupIds),      terminalIds: groupTerminalIds,    });  }   for (const terminalId of terminalIds) {    if (assignedTerminalIds.has(terminalId)) continue;    groups.push({      id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds),      terminalIds: [terminalId],    });  }   if (groups.length > 0) {    return groups;  }   return [    {      id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),      terminalIds: [DEFAULT_THREAD_TERMINAL_ID],    },  ];} function findGroupIndexByTerminalId(  terminalGroups: ThreadTerminalGroup[],  terminalId: string,): number {  return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));} function normalizeThreadTerminals(thread: Thread): Thread {  const terminalIds = normalizeTerminalIds(thread.terminalIds);  const activeTerminalId = terminalIds.includes(thread.activeTerminalId)    ? thread.activeTerminalId    : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);  const terminalGroups = normalizeTerminalGroups(thread, terminalIds);  const activeGroupIndexFromId = terminalGroups.findIndex(    (group) => group.id === thread.activeTerminalGroupId,  );  const activeGroupIndexFromTerminal = findGroupIndexByTerminalId(terminalGroups, activeTerminalId);  const activeGroupIndex =    activeGroupIndexFromId >= 0      ? activeGroupIndexFromId      : activeGroupIndexFromTerminal >= 0        ? activeGroupIndexFromTerminal        : 0;  const activeTerminalGroupId =    terminalGroups[activeGroupIndex]?.id ??    terminalGroups[0]?.id ??    fallbackGroupId(activeTerminalId);   return {    ...thread,    terminalIds,    runningTerminalIds: normalizeRunningTerminalIds(thread.runningTerminalIds, terminalIds),    activeTerminalId,    terminalGroups,    activeTerminalGroupId,  };} function closeThreadTerminal(thread: Thread, terminalId: string): Thread {  if (!thread.terminalIds.includes(terminalId)) {    return thread;  }   const remainingTerminalIds = thread.terminalIds.filter((id) => id !== terminalId);  if (remainingTerminalIds.length === 0) {    const nextTerminalGroupId = fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID);    return normalizeThreadTerminals({      ...thread,      terminalOpen: false,      terminalIds: [DEFAULT_THREAD_TERMINAL_ID],      runningTerminalIds: [],      activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,      terminalGroups: [        {          id: nextTerminalGroupId,          terminalIds: [DEFAULT_THREAD_TERMINAL_ID],        },      ],      activeTerminalGroupId: nextTerminalGroupId,    });  }   const closedTerminalIndex = thread.terminalIds.indexOf(terminalId);  const closedTerminalGroup = thread.terminalGroups.find((group) =>    group.terminalIds.includes(terminalId),  );  const closedTerminalGroupIndex = closedTerminalGroup    ? closedTerminalGroup.terminalIds.indexOf(terminalId)    : -1;  const remainingTerminalsInClosedGroup = (closedTerminalGroup?.terminalIds ?? []).filter(    (id) => id !== terminalId,  );  const nextActiveTerminalId =    thread.activeTerminalId === terminalId      ? (remainingTerminalsInClosedGroup[          Math.min(closedTerminalGroupIndex, remainingTerminalsInClosedGroup.length - 1)        ] ??        remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ??        remainingTerminalIds[0] ??        DEFAULT_THREAD_TERMINAL_ID)      : thread.activeTerminalId;  const nextTerminalGroups = thread.terminalGroups    .map((group) => ({      ...group,      terminalIds: group.terminalIds.filter((id) => id !== terminalId),    }))    .filter((group) => group.terminalIds.length > 0);   return normalizeThreadTerminals({    ...thread,    terminalIds: remainingTerminalIds,    runningTerminalIds: thread.runningTerminalIds.filter((id) => id !== terminalId),    activeTerminalId: nextActiveTerminalId,    terminalGroups: nextTerminalGroups,  });} // ── Reducer ────────────────────────────────────────────────────────── export function reducer(state: AppState, action: Action): AppState {  switch (action.type) {    case "ADD_PROJECT":      if (state.projects.some((project) => project.cwd === action.project.cwd)) {        return state;      }      return {        ...state,        projects: [          ...state.projects,          {            ...action.project,            model: resolveModelSlug(action.project.model),            scripts: normalizeProjectScripts(action.project.scripts),          },        ],      };     case "SET_PROJECT_SCRIPTS":      return {        ...state,        projects: state.projects.map((project) =>          project.id === action.projectId            ? { ...project, scripts: normalizeProjectScripts(action.scripts) }            : project,        ),      };     case "SET_THREADS_HYDRATED":      if (state.threadsHydrated === action.hydrated) {        return state;      }      return {        ...state,        threadsHydrated: action.hydrated,      };     case "APPLY_CORE_SNAPSHOT":      return mapSnapshotToState(action.snapshot, state);     case "APPLY_CORE_DELTA": {      const delta = action.delta;      switch (delta.kind) {        case "snapshot":          return mapSnapshotToState(delta.snapshot, state);        case "projectUpsert": {          const project: Project = {            id: delta.project.id,            name: delta.project.name,            cwd: delta.project.cwd,            model: delta.project.model,            expanded: delta.project.expanded,            scripts: mapCoreScriptsToProjectScripts(delta.project.scripts),          };          const existing = state.projects.find((item) => item.id === project.id);          return {            ...state,            projects: existing              ? state.projects.map((item) => (item.id === project.id ? project : item))              : [...state.projects, project],          };        }        case "projectDelete":          return {            ...state,            projects: state.projects.filter((project) => project.id !== delta.projectId),            threads: state.threads.filter((thread) => thread.projectId !== delta.projectId),          };        case "threadDelete":          return {            ...state,            threads: state.threads.filter((thread) => thread.id !== delta.threadId),          };        case "threadUpsert": {          const snapshot: CoreReadModelSnapshot = {            sequence: delta.sequence,            generatedAt: new Date().toISOString(),            projects: state.projects.map((project) => ({              id: project.id,              name: project.name,              cwd: project.cwd,              model: project.model,              expanded: project.expanded,              scripts: project.scripts.map((script) => ({                id: script.id,                name: script.name,                command: script.command,                icon: script.icon,                runOnWorktreeCreate: script.runOnWorktreeCreate,              })),            })),            threads: [              ...state.threads                .filter((thread) => thread.id !== delta.thread.id)                .map((thread) => ({                  id: thread.id,                  projectId: thread.projectId,                  title: thread.title,                  model: thread.model,                  createdAt: thread.createdAt,                  updatedAt: thread.latestTurnCompletedAt ?? thread.createdAt,                  sessionId: thread.session?.sessionId ?? null,                  messages: thread.messages.map((message) => ({                    id: message.id,                    role: message.role,

Lines 1–500 of 953.