852 lines · 31.5 KB · TypeScript
RawDownload
import { NativeStackScreenOptions } from "../../native/StackHeader";import {  StackActions,  useFocusEffect,  useNavigation,  type StaticScreenProps,} from "@react-navigation/native";import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";import * as Option from "effect/Option";import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";import { Platform, ScrollView, View } from "react-native";import { useSafeAreaInsets } from "react-native-safe-area-context";import { useWorkspaceState } from "../../state/workspace";import { useEnvironmentQuery } from "../../state/query";import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";import { vcsEnvironment } from "../../state/vcs"; import { EmptyState } from "../../components/EmptyState";import {  AndroidScreenHeader,  type AndroidHeaderAction,} from "../../components/AndroidScreenHeader";import { LoadingScreen } from "../../components/LoadingScreen";import { scopedThreadKey } from "../../lib/scopedEntities";import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";import { connectionTone } from "../connection/connectionTone"; import {  useRemoteConnections,  useRemoteConnectionStatus,  useRemoteEnvironmentRuntime,} from "../../state/use-remote-environment-registry";import { useKnownTerminalSessions } from "../../state/use-terminal-session";import { useSelectedThreadDetailState } from "../../state/use-thread-detail";import { useThreadSelection } from "../../state/use-thread-selection";import { GitActionProgressOverlay } from "./GitActionProgressOverlay";import {  buildTerminalMenuSessions,  nextOpenTerminalId,  resolveProjectScriptTerminalId,} from "../terminal/terminalMenu";import {  resolvePreferredThreadWorktreePath,  stagePendingTerminalLaunch,} from "../terminal/terminalLaunchContext";import { terminalDebugLog } from "../terminal/terminalDebugLog";import { ThreadDetailScreen } from "./ThreadDetailScreen";import {  ThreadGitControls,  useThreadGitCenterHeaderItems,  useThreadGitRightHeaderItems,} from "./ThreadGitControls";import { GitOverviewSheet } from "./git/GitOverviewSheet";import { useAtomCommand } from "../../state/use-atom-command";import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";import { useThreadComposerState } from "../../state/use-thread-composer-state";import { threadEnvironment } from "../../state/threads";import { projectThreadContentPresentation } from "./threadContentPresentation";import {  useAdaptiveWorkspaceLayout,  useAdaptiveWorkspacePaneRole,  useRegisterWorkspaceInspector,} from "../layout/AdaptiveWorkspaceLayout";import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane";import {  ThreadInspectorContentStack,  type ThreadInspectorMode,} from "./thread-inspector-content-stack"; interface ThreadInspectorSelection {  readonly routeThreadIdentity: string | null;  readonly mode: ThreadInspectorMode;} type NativeHeaderItems = ReadonlyArray<Record<string, unknown>>; function InspectorPaneRoleActivation() {  useAdaptiveWorkspacePaneRole("inspector");  return null;} function firstRouteParam(value: string | string[] | undefined): string | null {  if (Array.isArray(value)) {    return value[0] ?? null;  }   return value ?? null;} function OpeningThreadLoadingScreen() {  return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />;} type ThreadRouteScreenRouteProps = StaticScreenProps<{  readonly environmentId: string;  readonly threadId: string;}>; interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps {  readonly onReturnToThread?: () => void;  readonly renderInspector?: (headerInset: number) => ReactNode;} function ThreadUnavailableScreen() {  return (    <ScrollView      contentInsetAdjustmentBehavior="automatic"      contentContainerStyle={{        flexGrow: 1,        justifyContent: "center",        paddingHorizontal: 24,        paddingVertical: 32,      }}      className="bg-screen flex-1"    >      <EmptyState        title="Thread unavailable"        detail="This thread is not available in the current mobile snapshot."      />    </ScrollView>  );} export function ThreadRouteScreen(props: ThreadRouteScreenProps) {  const { state: workspaceState } = useWorkspaceState();  const { connectionState } = useRemoteConnectionStatus();  const { selectedThread } = useThreadSelection();  const params = props.route.params;  const environmentIdRaw = firstRouteParam(params.environmentId);  const threadIdRaw = firstRouteParam(params.threadId);  const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;  const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);  const routeConnectionState =    routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);  const routeThreadKey =    environmentId !== null && threadIdRaw !== null      ? scopedThreadKey(environmentId, ThreadId.make(threadIdRaw))      : null;  const selectedThreadKey =    selectedThread === null      ? null      : scopedThreadKey(selectedThread.environmentId, selectedThread.id);  const selectedThreadDetailState = useSelectedThreadDetailState();   if (environmentId === null || threadIdRaw === null) {    return <OpeningThreadLoadingScreen />;  }   // Render the full thread chrome (header, feed, composer) as soon as the  // thread SHELL is known — no blocking on message detail. The feed shows a  // loading placeholder while messages fetch, and the composer's connection  // pill reports connecting/reconnecting/syncing status.  if (selectedThread !== null && selectedThreadKey === routeThreadKey) {    return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />;  }   const stillHydrating =    workspaceState.isLoadingConnections ||    routeConnectionState === "connecting" ||    routeConnectionState === "reconnecting";   if (stillHydrating) {    return <OpeningThreadLoadingScreen />;  }   return <ThreadUnavailableScreen />;} function ThreadRouteContent(  props: ThreadRouteScreenProps & {    readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>;  },) {  const {    fileInspector,    layout,    panes,    showAuxiliaryPane,    toggleAuxiliaryPane,    togglePrimarySidebar,  } = useAdaptiveWorkspaceLayout();  const { connectionState } = useRemoteConnectionStatus();  const { onReconnectEnvironment } = useRemoteConnections();  const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =    useThreadSelection();  const selectedThreadDetailState = props.selectedThreadDetailState;  const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);  const { selectedThreadCwd } = useSelectedThreadWorktree();  const composer = useThreadComposerState();  const gitState = useSelectedThreadGitState();  const gitActions = useSelectedThreadGitActions();  const requests = useSelectedThreadRequests();  const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");  const navigation = useNavigation();  const params = props.route.params;  const environmentIdRaw = firstRouteParam(params.environmentId);  const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;  const threadId = firstRouteParam(params.threadId);  const routeThreadIdentity =    environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null;  const [inspectorSelection, setInspectorSelection] = useState<ThreadInspectorSelection | null>(    () => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null),  );  const inspectorMode = (() => {    if (inspectorSelection?.routeThreadIdentity === routeThreadIdentity) {      if (inspectorSelection.mode === "files" && selectedThreadCwd === null) {        return null;      }      return inspectorSelection.mode;    }    return null;  })();  useEffect(() => {    if (      fileInspector.supported &&      selectedThreadCwd === null &&      inspectorMode === null &&      panes.auxiliaryPaneVisible    ) {      toggleAuxiliaryPane();    }  }, [    fileInspector.supported,    inspectorMode,    panes.auxiliaryPaneVisible,    selectedThreadCwd,    toggleAuxiliaryPane,  ]);   useEffect(() => {    setInspectorSelection((current) => {      if (props.renderInspector === undefined) {        if (current === null || current.mode === "route") {          return null;        }        return { ...current, routeThreadIdentity };      }       if (current === null || current.mode === "route") {        return { routeThreadIdentity, mode: "route" };      }       return { ...current, routeThreadIdentity };    });  }, [props.renderInspector, routeThreadIdentity]);   useFocusEffect(    useCallback(() => {      return () => {        if (props.renderInspector === undefined) {          // Inspectors are contextual to this chat destination. Clear the          // hidden chat copy after a native push so returning from Files,          // Review, or Terminal cannot reserve an empty trailing pane.          setInspectorSelection(null);        }      };    }, [props.renderInspector]),  );  const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);  const routeConnectionState =    routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);  const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null;  const selectedThreadWithDraftSettings = useMemo(    () =>      selectedThread        ? {            ...selectedThread,            modelSelection: composer.modelSelection ?? selectedThread.modelSelection,            runtimeMode: composer.runtimeMode ?? selectedThread.runtimeMode,            interactionMode: composer.interactionMode ?? selectedThread.interactionMode,          }        : null,    [composer.interactionMode, composer.modelSelection, composer.runtimeMode, selectedThread],  );   /* ─── Native header theming ──────────────────────────────────────── */  const usesNativeHeaderGlass = NATIVE_LIQUID_GLASS_SUPPORTED;  const headerSubtitle = [    selectedThreadProject?.title ?? null,    selectedEnvironmentConnection?.environmentLabel ?? null,  ]    .filter(Boolean)    .join(" · ");  /* ─── Git status for native header trigger ───────────────────────── */  const gitStatus = useEnvironmentQuery(    selectedThread !== null && selectedThreadCwd !== null      ? vcsEnvironment.status({          environmentId: selectedThread.environmentId,          input: { cwd: selectedThreadCwd },        })      : null,  );  const knownTerminalSessions = useKnownTerminalSessions({    environmentId: selectedThread?.environmentId ?? null,    threadId: selectedThread?.id ?? null,  });  const terminalMenuSessions = useMemo(    () =>      buildTerminalMenuSessions({        knownSessions: knownTerminalSessions,        workspaceRoot: selectedThreadProject?.workspaceRoot ?? null,      }),    [knownTerminalSessions, selectedThreadProject?.workspaceRoot],  );  const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null;  const handleReconnectEnvironment = useCallback(() => {    if (!environmentId) {      return;    }    onReconnectEnvironment(environmentId);  }, [environmentId, onReconnectEnvironment]);   /* ─── Git action progress (for overlay banner) ──────────────────── */  const gitActionProgressTarget = useMemo(    () => ({      environmentId: selectedThread?.environmentId ?? null,      cwd: selectedThreadCwd,    }),    [selectedThread?.environmentId, selectedThreadCwd],  );  const gitActionProgress = useGitActionProgress(gitActionProgressTarget);   const handleOpenGitInspector = useCallback(() => {    if (!fileInspector.supported) {      if (selectedThread === null) {        return;      }      navigation.navigate("GitOverview", {        environmentId: String(selectedThread.environmentId),        threadId: String(selectedThread.id),      });      return;    }    setInspectorSelection({ routeThreadIdentity, mode: "git" });    showAuxiliaryPane("inspector");  }, [fileInspector.supported, navigation, routeThreadIdentity, selectedThread, showAuxiliaryPane]);  const handleOpenFilesInspector = useCallback(() => {    if (selectedThread === null || selectedThreadCwd === null) {      return;    }    if (!fileInspector.supported) {      navigation.navigate("ThreadFiles", {        environmentId: String(selectedThread.environmentId),        threadId: String(selectedThread.id),      });      return;    }    setInspectorSelection({      routeThreadIdentity,      mode: props.renderInspector === undefined ? "files" : "route",    });    showAuxiliaryPane("inspector");  }, [    fileInspector.supported,    navigation,    props.renderInspector,    routeThreadIdentity,    selectedThread,    selectedThreadCwd,    showAuxiliaryPane,  ]);  const inspectorToggleActionRef = useRef({    inspectorMode,    openFilesInspector: handleOpenFilesInspector,    toggleAuxiliaryPane,  });  inspectorToggleActionRef.current = {    inspectorMode,    openFilesInspector: handleOpenFilesInspector,    toggleAuxiliaryPane,  };  const handleToggleInspector = useCallback(() => {    const action = inspectorToggleActionRef.current;    if (action.inspectorMode === null) {      action.openFilesInspector();      return;    }    action.toggleAuxiliaryPane();  }, []);  const handleSelectInspectorFile = useCallback(    (path: string) => {      if (selectedThread === null) {        return;      }      const params = {        environmentId: String(selectedThread.environmentId),        threadId: String(selectedThread.id),        path: path.split("/").filter((segment) => segment.length > 0),      };      if (fileInspector.supported) {        navigation.navigate("ThreadFile", params);        return;      }      navigation.navigate("ThreadFile", params);    },    [fileInspector.supported, navigation, selectedThread],  );  // The workspace inspector column spans the full window height. On iOS the  // panes bring their own nested native headers (which underlap the status  // bar); elsewhere the pane content pads itself below the top inset.  const safeAreaInsets = useSafeAreaInsets();  const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top;  const GitInspector = useCallback(    () => (      <GitOverviewSheet        headerInset={inspectorHeaderInset}        presentation="inspector"        route={{ params: props.route.params }}      />    ),    [inspectorHeaderInset, props.route.params],  );  const FilesInspector = useCallback(    () =>      selectedThread !== null && selectedThreadCwd !== null ? (        <ThreadFileNavigatorPane          cwd={selectedThreadCwd}          environmentId={selectedThread.environmentId}          headerInset={inspectorHeaderInset}          projectName={selectedThreadProject?.title ?? "Files"}          selectedPath={null}          onSelectFile={handleSelectInspectorFile}        />      ) : null,    [      handleSelectInspectorFile,      inspectorHeaderInset,      selectedThread,      selectedThreadCwd,      selectedThreadProject?.title,    ],  );  const RouteInspector = useCallback(    () => props.renderInspector?.(inspectorHeaderInset),    [inspectorHeaderInset, props.renderInspector],  );  const renderInspectorStack = useCallback(    () =>      inspectorMode === null ? null : (        <ThreadInspectorContentStack          Files={FilesInspector}          Git={GitInspector}          mode={inspectorMode}          Route={props.renderInspector ? RouteInspector : undefined}        />      ),    [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector],  );  const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack;  // Hand the inspector to the workspace so it renders beside the navigator,  // outside this screen's native header — the terminal/git/files toolbar  // stays anchored to the chat pane instead of floating above the inspector.  useRegisterWorkspaceInspector(activeInspectorRenderer);   const handleOpenConnectionEditor = useCallback(() => {    void navigation.navigate("Connections");  }, [navigation]);  const handleStopThread = useCallback(() => {    if (      !selectedThread ||      (selectedThread.session?.status !== "running" &&        selectedThread.session?.status !== "starting")    ) {      return;    }    return interruptThreadTurn({      environmentId: selectedThread.environmentId,      input: {        threadId: selectedThread.id,        ...(selectedThread.session.activeTurnId          ? { turnId: selectedThread.session.activeTurnId }          : {}),      },    });  }, [interruptThreadTurn, selectedThread]);   const handleOpenTerminal = useCallback(    (nextTerminalId?: string | null) => {      terminalDebugLog("terminal-menu:open-existing", {        terminalId: nextTerminalId ?? null,        hasThread: Boolean(selectedThread),        hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),      });       if (!selectedThread || !selectedThreadProject?.workspaceRoot) {        return;      }       void navigation.navigate("ThreadTerminal", {        environmentId: String(selectedThread.environmentId),        threadId: String(selectedThread.id),        ...(nextTerminalId ? { terminalId: nextTerminalId } : {}),      });    },    [navigation, selectedThread, selectedThreadProject?.workspaceRoot],

Lines 1–500 of 852.