476 lines · 21.8 KB · TypeScript
RawDownload
import { useAtomValue } from "@effect/atom-react";import {  scopedProjectKey,  scopeProjectRef,  scopeThreadRef,} from "@t3tools/client-runtime/environment";import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts";import { useParams, useRouter } from "@tanstack/react-router";import { useCallback, useMemo } from "react";import {  composerDraftHasUserContent,  markPromotedDraftThreadByRef,  type DraftId,  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 { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";import { readThreadShell, useProjects, useThread } from "../state/entities";import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";import { primaryServerSettingsAtom } from "../state/server";import { resolveThreadRouteTarget } from "../threadRoutes";import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";import { useClientSettings } from "./useSettings"; interface NewThreadWorkspaceOptions {  branch?: string | null;  worktreePath?: string | null;  envMode?: DraftThreadEnvMode;  startFromOrigin?: boolean;} // The workspace options the caller passed explicitly, shaped for the draft// store: absent keys stay absent so they never overwrite existing draft// state. Every reuse path applies exactly this set.function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undefined) {  return {    ...(options?.branch !== undefined ? { branch: options.branch } : {}),    ...(options?.worktreePath !== undefined ? { worktreePath: options.worktreePath } : {}),    ...(options?.envMode !== undefined ? { envMode: options.envMode } : {}),    ...(options?.startFromOrigin !== undefined ? { startFromOrigin: options.startFromOrigin } : {}),  };} 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;        /**         * Move the viewed draft's typed content (prompt + images) into the         * draft this request lands on. Set by the draft repo picker: the         * user started writing in the wrong project and the text should         * follow them. Explicit new-thread surfaces leave this unset and         * keep mint-fresh semantics.         */        carryComposerContent?: boolean;      },      // Which draft the thread ended up in, so a caller that has something to put in it — a      // prepared checkout, a task to write — addresses that one rather than looking the project      // up again and finding whichever draft it happens to hold.    ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => {      const {        getComposerDraft,        getDraftSessionByLogicalProjectKey,        getDraftSession,        getDraftThread,        applyStickyState,        moveComposerPromptAndImages,        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;      // Content only moves when the caller opted in and the user is looking      // at a draft. The content check happens at move time, not here: the      // paths below await, and text typed during those awaits must still      // come along.      const carryContentSourceDraftId =        options?.carryComposerContent === true && currentRouteTarget?.kind === "draft"          ? currentRouteTarget.draftId          : null;      const carryComposerContentTo = (destinationDraftId: DraftId) => {        if (          carryContentSourceDraftId &&          carryContentSourceDraftId !== destinationDraftId &&          // Never clobber a destination the user already invested in — the          // move overwrites the destination prompt, so a concurrent repo          // change that carried content first must win.          !composerDraftHasUserContent(getComposerDraft(destinationDraftId)) &&          composerDraftHasUserContent(getComposerDraft(carryContentSourceDraftId))        ) {          moveComposerPromptAndImages(carryContentSourceDraftId, destinationDraftId);        }      };      const project = projects.find(        (candidate) =>          candidate.id === projectRef.projectId &&          candidate.environmentId === projectRef.environmentId,      );      // The shared resolver owns the priority order. The t3.json read is      // skipped entirely when a higher-priority source decides, and its      // query atom caches per project after the first call.      const resolveDefaultEnvMode = async (): Promise<DraftThreadEnvMode> => {        const consultProjectFile = project !== undefined && project.defaultThreadEnvMode == null;        return resolveDefaultThreadEnvMode({          projectSetting: project?.defaultThreadEnvMode,          projectFile: consultProjectFile            ? await readT3ProjectFileDefaultThreadEnvMode(                project.environmentId,                project.workspaceRoot,              )            : null,          globalDefault: primaryServerSettings.defaultThreadEnvMode,        });      };      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 =        storedDraftThread !== null &&        storedDraftThread.promotedTo == null &&        storedDraftThreadRef !== null &&        readThreadShell(storedDraftThreadRef) === null          ? storedDraftThread          : null;      if (storedDraftThreadRef && reusableStoredDraftThread === null) {        markPromotedDraftThreadByRef(storedDraftThreadRef);      }      // New-thread surfaces (button, hotkeys, "/" landing, palette) only      // ever reuse a draft the user has NOT invested in. A draft with typed      // text or attachments is work in progress: it stays alive where it is      // (reachable from the sidebar draft rows) and this request mints a      // fresh draft instead — the remap in the store preserves invested      // drafts rather than deleting them.      const emptyStoredDraftThread =        reusableStoredDraftThread &&        !composerDraftHasUserContent(getComposerDraft(reusableStoredDraftThread.draftId))          ? reusableStoredDraftThread          : null;      const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget        ? currentRouteTarget.kind === "server"          ? getDraftThread(currentRouteTarget.threadRef)          : getDraftSession(currentRouteTarget.draftId)        : null;      if (emptyStoredDraftThread) {        return (async () => {          const isDraftAlreadyOpen =            currentRouteTarget?.kind === "draft" &&            currentRouteTarget.draftId === emptyStoredDraftThread.draftId;          const hasExplicitWorkspaceOption =            hasBranchOption ||            hasWorktreePathOption ||            hasEnvModeOption ||            hasStartFromOriginOption;          // Resurrecting an empty 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. 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.          let workspaceContext: NewThreadWorkspaceOptions | null = null;          if (hasExplicitWorkspaceOption) {            workspaceContext = pickExplicitWorkspaceOptions(options);          } else if (!isDraftAlreadyOpen) {            const defaultEnvMode = await resolveDefaultEnvMode();            // The await yields. If the draft was opened (a concurrent            // invocation's navigation landed), promoted to a real thread,            // remapped away (a concurrent invocation registered a fresh            // draft — remapping back would evict the winner and let the            // store GC it), or gained content (no longer a reusable empty            // draft) in the meantime, this invocation is a stale loser:            // resetting context, remapping, or navigating would all clobber            // state written after the snapshot above. Bail out entirely —            // the winner already did this work.            const routeTargetNow = getCurrentRouteTarget();            const openedMeanwhile =              routeTargetNow?.kind === "draft" &&              routeTargetNow.draftId === emptyStoredDraftThread.draftId;            const promotedMeanwhile =              storedDraftThreadRef !== null && readThreadShell(storedDraftThreadRef) !== null;            const remappedMeanwhile =              getDraftSessionByLogicalProjectKey(logicalProjectKey)?.draftId !==              emptyStoredDraftThread.draftId;            const investedMeanwhile = composerDraftHasUserContent(              getComposerDraft(emptyStoredDraftThread.draftId),            );            if (openedMeanwhile || promotedMeanwhile || remappedMeanwhile || investedMeanwhile) {              return null;            }            workspaceContext = {              branch: null,              worktreePath: null,              envMode: defaultEnvMode,              startFromOrigin: resolveNewDraftStartFromOrigin({                envMode: defaultEnvMode,                newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,              }),            };          }          if (workspaceContext) {            setDraftThreadContext(emptyStoredDraftThread.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(emptyStoredDraftThread.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, undoing the write above.          setLogicalProjectDraftThreadId(            logicalProjectKey,            projectRef,            emptyStoredDraftThread.draftId,            {              threadId: emptyStoredDraftThread.threadId,              ...workspaceContext,              ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),              ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),            },          );          carryComposerContentTo(emptyStoredDraftThread.draftId);          const opened = {            draftId: emptyStoredDraftThread.draftId,            threadId: emptyStoredDraftThread.threadId,          };          // Re-read the route: the snapshot from before the await is stale          // once a concurrent invocation's navigation lands, and navigating          // again would push a duplicate history entry.          const routeTargetAfterWrites = getCurrentRouteTarget();          if (            routeTargetAfterWrites?.kind === "draft" &&            routeTargetAfterWrites.draftId === emptyStoredDraftThread.draftId          ) {            return opened;          }          await router.navigate({            to: "/draft/$draftId",            params: { draftId: emptyStoredDraftThread.draftId },            replace: options?.replace ?? false,          });          return opened;        })();      }       if (        latestActiveDraftThread &&        currentRouteTarget?.kind === "draft" &&        latestActiveDraftThread.logicalProjectKey === logicalProjectKey &&        latestActiveDraftThread.promotedTo == null &&        // Same content rule as above: a new-thread request while viewing an        // invested draft mints a fresh one instead of repurposing it.        !composerDraftHasUserContent(getComposerDraft(currentRouteTarget.draftId))      ) {        if (          hasBranchOption ||          hasWorktreePathOption ||          hasEnvModeOption ||          hasStartFromOriginOption        ) {          setDraftThreadContext(currentRouteTarget.draftId, pickExplicitWorkspaceOptions(options));        }        setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, {          threadId: latestActiveDraftThread.threadId,          createdAt: latestActiveDraftThread.createdAt,          runtimeMode: latestActiveDraftThread.runtimeMode,          interactionMode: latestActiveDraftThread.interactionMode,          ...pickExplicitWorkspaceOptions(options),        });        return Promise.resolve({          draftId: currentRouteTarget.draftId,          threadId: latestActiveDraftThread.threadId,        });      }       const draftId = newDraftId();      const threadId = newThreadId();      const createdAt = new Date().toISOString();      return (async () => {        const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode());        // The await yields, so a concurrent invocation may have registered a        // draft for this logical project in the meantime. Registering ours        // too would evict that draft while its navigation is in flight —        // reuse the winner instead, like the synchronous path above does.        const racedDraft = getDraftSessionByLogicalProjectKey(logicalProjectKey);        if (          racedDraft &&          // Only a draft REGISTERED during the await counts as a raced          // winner. An invested draft this invocation deliberately declined          // to reuse is still mapped at this point — reusing it here would          // silently undo mint-fresh semantics.          racedDraft.draftId !== storedDraftThread?.draftId &&          readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null        ) {          // Same remap the reuse paths above perform: point the draft at the          // caller's project member and apply explicit workspace options if          // the caller passed any. Without explicit options the winner's          // context stands untouched — the winner's navigation is landing,          // which is the isDraftAlreadyOpen "leave it alone" case. Writing          // this invocation's defaults here instead would clobber the          // winner's explicit picks and could pair its worktreePath with a          // contradictory envMode.          setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, racedDraft.draftId, {            threadId: racedDraft.threadId,            createdAt: racedDraft.createdAt,            runtimeMode: racedDraft.runtimeMode,            interactionMode: racedDraft.interactionMode,            ...pickExplicitWorkspaceOptions(options),          });          carryComposerContentTo(racedDraft.draftId);          await router.navigate({            to: "/draft/$draftId",            params: { draftId: racedDraft.draftId },            replace: options?.replace ?? false,          });          return { draftId: racedDraft.draftId, threadId: racedDraft.threadId };        }        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 });        }        carryComposerContentTo(draftId);         await router.navigate({          to: "/draft/$draftId",          params: { draftId },          replace: options?.replace ?? false,        });        return { draftId, threadId };      })();    },    [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,  };}