325 lines · 14.0 KB · TypeScript
RawDownload
import { useAtomValue } from "@effect/atom-react";import {  scopedProjectKey,  scopeProjectRef,  scopeThreadRef,} from "@t3tools/client-runtime/environment";import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts";import { useParams, useRouter } from "@tanstack/react-router";import { useCallback, useMemo } from "react";import {  markPromotedDraftThreadByRef,  type DraftThreadEnvMode,  type DraftThreadState,  useComposerDraftStore,} from "../composerDraftStore";import { newDraftId, newThreadId } from "../lib/utils";import { orderItemsByPreferredIds } from "../components/Sidebar.logic";import {  deriveLogicalProjectKeyFromSettings,  getProjectOrderKey,  selectProjectGroupingSettings,} from "../logicalProject";import { readThreadShell, useProjects, useThread } from "../state/entities";import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";import { primaryServerSettingsAtom } from "../state/server";import { resolveThreadRouteTarget } from "../threadRoutes";import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";import { useClientSettings } from "./useSettings"; export function useNewThreadHandler() {  const projects = useProjects();  // New-thread defaults are a user preference, and the settings UI only ever  // edits the primary environment's settings.json. Reading the target  // environment's own settings here would silently reset remote projects to  // the decoded defaults ("local" mode, current branch), since nothing can  // set those values on a remote server.  const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);  const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);  const router = useRouter();  const getCurrentRouteTarget = useCallback(() => {    const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};    return resolveThreadRouteTarget(currentRouteParams);  }, [router]);   return useCallback(    (      projectRef: ScopedProjectRef,      options?: {        branch?: string | null;        worktreePath?: string | null;        envMode?: DraftThreadEnvMode;        startFromOrigin?: boolean;        replace?: boolean;      },    ): Promise<void> => {      const {        getComposerDraft,        getDraftSessionByLogicalProjectKey,        getDraftSession,        getDraftThread,        applyStickyState,        setDraftThreadContext,        setLogicalProjectDraftThreadId,        setModelSelection,      } = useComposerDraftStore.getState();      const currentRouteTarget = getCurrentRouteTarget();      // A new thread carries the user's *working mode* from the thread being      // viewed: model (including options like reasoning effort and context      // window), permission mode, and interaction mode. Branch, worktree, and      // env mode never carry implicitly — those come from the configured      // defaults unless the caller passes them explicitly.      const carrySourceShell =        currentRouteTarget?.kind === "server"          ? readThreadShell(currentRouteTarget.threadRef)          : null;      const carrySourceDraft =        currentRouteTarget?.kind === "draft" ? getDraftSession(currentRouteTarget.draftId) : null;      // Composer overrides win over the persisted thread state — they are      // what the user currently sees in the composer controls.      const carrySourceComposer = currentRouteTarget        ? getComposerDraft(            currentRouteTarget.kind === "server"              ? currentRouteTarget.threadRef              : currentRouteTarget.draftId,          )        : null;      const composerActiveProvider = carrySourceComposer?.activeProvider ?? null;      const composerModelSelection = composerActiveProvider        ? (carrySourceComposer?.modelSelectionByProvider[composerActiveProvider] ?? null)        : null;      const carryModelSelection =        composerModelSelection ?? carrySourceShell?.modelSelection ?? null;      const carryRuntimeMode =        carrySourceComposer?.runtimeMode ??        carrySourceShell?.runtimeMode ??        carrySourceDraft?.runtimeMode ??        null;      const carryInteractionMode =        carrySourceComposer?.interactionMode ??        carrySourceShell?.interactionMode ??        carrySourceDraft?.interactionMode ??        null;      const project = projects.find(        (candidate) =>          candidate.id === projectRef.projectId &&          candidate.environmentId === projectRef.environmentId,      );      const logicalProjectKey = project        ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)        : scopedProjectKey(projectRef);      const hasBranchOption = options?.branch !== undefined;      const hasWorktreePathOption = options?.worktreePath !== undefined;      const hasEnvModeOption = options?.envMode !== undefined;      const hasStartFromOriginOption = options?.startFromOrigin !== undefined;      const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey);      const storedDraftThreadRef = storedDraftThread        ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId)        : null;      const reusableStoredDraftThread =        storedDraftThreadRef && readThreadShell(storedDraftThreadRef) !== null          ? null          : storedDraftThread;      if (storedDraftThreadRef && reusableStoredDraftThread === null) {        markPromotedDraftThreadByRef(storedDraftThreadRef);      }      const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget        ? currentRouteTarget.kind === "server"          ? getDraftThread(currentRouteTarget.threadRef)          : getDraftSession(currentRouteTarget.draftId)        : null;      if (reusableStoredDraftThread) {        return (async () => {          const isDraftAlreadyOpen =            currentRouteTarget?.kind === "draft" &&            currentRouteTarget.draftId === reusableStoredDraftThread.draftId;          const hasExplicitWorkspaceOption =            hasBranchOption ||            hasWorktreePathOption ||            hasEnvModeOption ||            hasStartFromOriginOption;          // Resurrecting a stored draft must not resurrect its stale context:          // explicit workspace options win outright; otherwise the env context          // resets to the configured defaults so drafts seeded before a          // defaults change (or by the old carry-over behavior) stop landing          // on "current checkout" branches forever. Composer text is          // preserved. When the draft is already open and no options were          // passed, leave it alone entirely — the user may have just picked a          // branch in the composer.          const defaultEnvMode = primaryServerSettings.defaultThreadEnvMode;          const workspaceContext = hasExplicitWorkspaceOption            ? {                ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),                ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),                ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),                ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),              }            : isDraftAlreadyOpen              ? null              : {                  branch: null,                  worktreePath: null,                  envMode: defaultEnvMode,                  startFromOrigin: resolveNewDraftStartFromOrigin({                    envMode: defaultEnvMode,                    newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,                  }),                };          if (workspaceContext) {            setDraftThreadContext(reusableStoredDraftThread.draftId, {              ...workspaceContext,              ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),              ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),            });            if (carryModelSelection) {              // The carried selection is a complete snapshot of the viewed              // thread's model state: absent options mean "no options", not              // "keep the stale draft's options".              setModelSelection(reusableStoredDraftThread.draftId, carryModelSelection, {                replaceOptions: true,              });            }          }          // The workspace context must also ride along here: when projectRef          // targets a different physical member of the logical project,          // createDraftThreadState treats the remap as a project change and          // would otherwise wipe branch/worktree and force "local" mode,          // undoing the write above.          setLogicalProjectDraftThreadId(            logicalProjectKey,            projectRef,            reusableStoredDraftThread.draftId,            {              threadId: reusableStoredDraftThread.threadId,              ...(workspaceContext ?? {}),              ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),              ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),            },          );          if (            currentRouteTarget?.kind === "draft" &&            currentRouteTarget.draftId === reusableStoredDraftThread.draftId          ) {            return;          }          await router.navigate({            to: "/draft/$draftId",            params: { draftId: reusableStoredDraftThread.draftId },            replace: options?.replace ?? false,          });        })();      }       if (        latestActiveDraftThread &&        currentRouteTarget?.kind === "draft" &&        latestActiveDraftThread.logicalProjectKey === logicalProjectKey &&        latestActiveDraftThread.promotedTo == null      ) {        if (          hasBranchOption ||          hasWorktreePathOption ||          hasEnvModeOption ||          hasStartFromOriginOption        ) {          setDraftThreadContext(currentRouteTarget.draftId, {            ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),            ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),            ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),            ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),          });        }        setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, {          threadId: latestActiveDraftThread.threadId,          createdAt: latestActiveDraftThread.createdAt,          runtimeMode: latestActiveDraftThread.runtimeMode,          interactionMode: latestActiveDraftThread.interactionMode,          ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),          ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),          ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),          ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),        });        return Promise.resolve();      }       const draftId = newDraftId();      const threadId = newThreadId();      const createdAt = new Date().toISOString();      const initialEnvMode = options?.envMode ?? primaryServerSettings.defaultThreadEnvMode;      return (async () => {        setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {          threadId,          createdAt,          branch: options?.branch ?? null,          worktreePath: options?.worktreePath ?? null,          envMode: initialEnvMode,          startFromOrigin:            options?.startFromOrigin ??            resolveNewDraftStartFromOrigin({              envMode: initialEnvMode,              newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,            }),          runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE,          ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),        });        applyStickyState(draftId);        if (carryModelSelection) {          // After sticky state so the viewed thread's exact selection          // (model + options like effort and context window) wins over the          // globally sticky one. replaceOptions: the carried selection is a          // complete snapshot — absent options mean "no options", not "keep          // whatever sticky state just wrote".          setModelSelection(draftId, carryModelSelection, { replaceOptions: true });        }         await router.navigate({          to: "/draft/$draftId",          params: { draftId },          replace: options?.replace ?? false,        });      })();    },    [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, projects, router],  );} export function useHandleNewThread() {  const projectOrder = useUiStateStore((store) => store.projectOrder);  const routeTarget = useParams({    strict: false,    select: (params) => resolveThreadRouteTarget(params),  });  const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null;  const activeThread = useThread(routeThreadRef);  const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);  const activeDraftThread = useComposerDraftStore(() =>    routeTarget      ? routeTarget.kind === "server"        ? getDraftThread(routeTarget.threadRef)        : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId)      : null,  );  const projects = useProjects();  const orderedProjects = useMemo(() => {    return orderItemsByPreferredIds({      items: projects,      preferredIds: projectOrder,      getId: getProjectOrderKey,      getPreferenceIds: (project) => [        getProjectOrderKey(project),        legacyProjectCwdPreferenceKey(project.workspaceRoot),      ],    });  }, [projectOrder, projects]);  const handleNewThread = useNewThreadHandler();   return {    activeDraftThread,    activeThread,    defaultProjectRef: orderedProjects[0]      ? scopeProjectRef(orderedProjects[0].environmentId, orderedProjects[0].id)      : null,    handleNewThread,    routeThreadRef,  };}