792 lines · 27.0 KB · TypeScript
RawDownload
import {  type Dispatch,  type ReactNode,  createContext,  createElement,  useContext,  useEffect,  useReducer,} from "react"; import {  DEFAULT_MODEL,  ProviderSessionId,  ThreadId,  type OrchestrationReadModel,  type OrchestrationSessionStatus,  resolveModelSlug,} from "@t3tools/contracts";import {  DEFAULT_THREAD_TERMINAL_HEIGHT,  DEFAULT_THREAD_TERMINAL_ID,  DEFAULT_RUNTIME_MODE,  MAX_THREAD_TERMINAL_COUNT,  type ChatMessage,  type Project,  type RuntimeMode,  type Thread,  type ThreadTerminalGroup,} from "./types"; // ── Actions ────────────────────────────────────────────────────────── type Action =  | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }  | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string }  | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId }  | { type: "TOGGLE_PROJECT"; projectId: Project["id"] }  | {      type: "SET_THREAD_TERMINAL_ACTIVITY";      threadId: ThreadId;      terminalId: string;      hasRunningSubprocess: boolean;    }  | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }  | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }  | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }  | { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }  | { type: "NEW_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }  | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: ThreadId; terminalId: string }  | { type: "CLOSE_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }  | { type: "SET_ERROR"; threadId: ThreadId; error: string | null }  | {      type: "SET_THREAD_BRANCH";      threadId: ThreadId;      branch: string | null;      worktreePath: string | null;    }  | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; // ── State ──────────────────────────────────────────────────────────── export interface AppState {  projects: Project[];  threads: Thread[];  threadsHydrated: boolean;  runtimeMode: RuntimeMode;} const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";const LEGACY_PERSISTED_STATE_KEYS = [  "t3code:renderer-state:v6",  "t3code:renderer-state:v5",  "t3code:renderer-state:v4",  "t3code:renderer-state:v3",  "codething:renderer-state:v4",  "codething:renderer-state:v3",  "codething:renderer-state:v2",  "codething:renderer-state:v1",] as const; const initialState: AppState = {  projects: [],  threads: [],  threadsHydrated: false,  runtimeMode: DEFAULT_RUNTIME_MODE,};const persistedExpandedProjectCwds = new Set<string>(); // ── Helpers ────────────────────────────────────────────────────────── function readPersistedState(): AppState {  if (typeof window === "undefined") return initialState;   try {    const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);    if (!raw) return initialState;    const parsed = JSON.parse(raw) as {      runtimeMode?: RuntimeMode;      expandedProjectCwds?: string[];    };    persistedExpandedProjectCwds.clear();    for (const cwd of parsed.expandedProjectCwds ?? []) {      if (typeof cwd === "string" && cwd.length > 0) {        persistedExpandedProjectCwds.add(cwd);      }    }    return {      ...initialState,      runtimeMode:        parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access"          ? parsed.runtimeMode          : DEFAULT_RUNTIME_MODE,    };  } catch {    return initialState;  }} function persistState(state: AppState): void {  if (typeof window === "undefined") return;   try {    window.localStorage.setItem(      PERSISTED_STATE_KEY,      JSON.stringify({        runtimeMode: state.runtimeMode,        expandedProjectCwds: state.projects          .filter((project) => project.expanded)          .map((project) => project.cwd),      }),    );    for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {      window.localStorage.removeItem(legacyKey);    }  } catch {    // Ignore quota/storage errors to avoid breaking chat UX.  }} function updateThread(  threads: Thread[],  threadId: ThreadId,  updater: (t: Thread) => Thread,): Thread[] {  return threads.map((t) => (t.id === threadId ? updater(t) : t));} function mapProjectsFromReadModel(  incoming: OrchestrationReadModel["projects"],  previous: Project[],): Project[] {  return incoming.map((project) => {    const existing =      previous.find((entry) => entry.id === project.id) ??      previous.find((entry) => entry.cwd === project.workspaceRoot);    return {      id: project.id,      name: project.title,      cwd: project.workspaceRoot,      model: existing?.model ?? resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL),      expanded:        existing?.expanded ??        (persistedExpandedProjectCwds.size > 0          ? persistedExpandedProjectCwds.has(project.workspaceRoot)          : true),      scripts: project.scripts.map((script) => ({ ...script })),    };  });} function toLegacySessionStatus(  status: OrchestrationSessionStatus,): "connecting" | "ready" | "running" | "error" | "closed" {  switch (status) {    case "starting":      return "connecting";    case "running":      return "running";    case "error":      return "error";    case "ready":    case "interrupted":      return "ready";    case "idle":    case "stopped":      return "closed";  }} function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" {  return providerName === "claudeCode" ? "claudeCode" : "codex";} 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,  });} function setThreadTerminalActivity(  thread: Thread,  terminalId: string,  hasRunningSubprocess: boolean,): Thread {  const normalizedThread = normalizeThreadTerminals(thread);  if (!normalizedThread.terminalIds.includes(terminalId)) {    return normalizedThread;  }  const runningTerminalIds = new Set(normalizedThread.runningTerminalIds);  if (hasRunningSubprocess) {    runningTerminalIds.add(terminalId);  } else {    runningTerminalIds.delete(terminalId);  }  return normalizeThreadTerminals({    ...normalizedThread,    runningTerminalIds: [...runningTerminalIds],  });} // ── Reducer ────────────────────────────────────────────────────────── export function reducer(state: AppState, action: Action): AppState {  switch (action.type) {    case "SYNC_SERVER_READ_MODEL": {      const projects = mapProjectsFromReadModel(        action.readModel.projects.filter((project) => project.deletedAt === null),        state.projects,      );      const existingThreadById = new Map(        state.threads.map((thread) => [thread.id, thread] as const),      );      const threads = action.readModel.threads        .filter((thread) => thread.deletedAt === null)        .map((thread) => {          const existing = existingThreadById.get(thread.id);          const latestTurnStartedAt =            thread.latestTurnId === null              ? undefined              : thread.messages.find(                  (message) => message.turnId === thread.latestTurnId && message.role === "user",                )?.createdAt;          const latestTurnCompletedAt = thread.checkpoints.find(            (checkpoint) => checkpoint.turnId === thread.latestTurnId,          )?.completedAt;          const latestTurnDurationMs =            latestTurnStartedAt && latestTurnCompletedAt              ? Math.max(0, Date.parse(latestTurnCompletedAt) - Date.parse(latestTurnStartedAt))              : undefined;           return normalizeThreadTerminals({            id: thread.id,            codexThreadId: thread.session?.providerThreadId ?? null,            projectId: thread.projectId,            title: thread.title,            model: resolveModelSlug(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: thread.session              ? {                  sessionId:                    thread.session.providerSessionId ??                    ProviderSessionId.makeUnsafe(`thread:${thread.id}`),                  provider: toLegacyProvider(thread.session.providerName),                  status: toLegacySessionStatus(thread.session.status),                  orchestrationStatus: thread.session.status,                  threadId: thread.session.providerThreadId,                  activeTurnId: thread.session.activeTurnId ?? undefined,                  createdAt: thread.session.updatedAt,                  updatedAt: thread.session.updatedAt,                  ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),                }              : null,            messages: thread.messages.map((message) => {              const attachments = message.attachments?.map((attachment, index) => ({                type: "image" as const,                id: `${message.id}:${index}`,                name: attachment.name,                mimeType: attachment.mimeType,                sizeBytes: attachment.sizeBytes,                previewUrl: attachment.dataUrl,              }));              const normalizedMessage: ChatMessage = {                id: message.id,                role: message.role,                text: message.text,                createdAt: message.createdAt,                streaming: message.streaming,                ...(message.streaming ? {} : { completedAt: message.updatedAt }),                ...(attachments && attachments.length > 0 ? { attachments } : {}),              };              return normalizedMessage;            }),            error: thread.session?.lastError ?? null,            createdAt: thread.createdAt,            latestTurnId: thread.latestTurnId ?? undefined,            latestTurnStartedAt,            latestTurnCompletedAt,            latestTurnDurationMs,            lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,            branch: thread.branch,            worktreePath: thread.worktreePath,            turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({              turnId: checkpoint.turnId,              completedAt: checkpoint.completedAt,              status: checkpoint.status,              assistantMessageId: checkpoint.assistantMessageId ?? undefined,              checkpointTurnCount: checkpoint.checkpointTurnCount,              checkpointRef: checkpoint.checkpointRef,              files: checkpoint.files.map((file) => ({ ...file })),

Lines 1–500 of 792.