import { scopedProjectKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { DEFAULT_SERVER_SETTINGS, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts"; import { type AnyRouter, useRouter } from "@tanstack/react-router"; 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 "@t3tools/client-runtime/state/project-grouping"; import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { readProject, readProjects, readThreadShell, readThreadShells, useProjects, useQuietThreadShell, } from "../state/entities"; import { hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, resolveNewThreadModelSelectionOverride, } from "../lib/chatThreadActions"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { readT3ProjectFile } from "../lib/t3ProjectFileDefaults"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentSummaries } from "../state/presentation"; import { environmentServerConfigsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { getClientSettings } from "./useSettings"; import { useThreadRouteTarget } from "./useThreadRouteTarget"; import { readUnloadedThreadCount } from "../state/olderSettledShelf"; interface NewThreadWorkspaceOptions { branch?: string | null; worktreePath?: string | null; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; } 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() { return createNewThreadHandler(useRouter()); } export type NewThreadHandler = ReturnType; export function createNewThreadHandler(router: AnyRouter) { const getCurrentRouteTarget = () => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; return resolveThreadRouteTarget(currentRouteParams); }; return ( projectRef: ScopedProjectRef, options?: { branch?: string | null; worktreePath?: string | null; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; replace?: boolean; /** Composer text for the draft, set before it shows. A draft with user text keeps it. */ prompt?: string; }, ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { const projects = readProjects(); const projectGroupingSettings = selectProjectGroupingSettings(getClientSettings()); // Read at call time; status pushes replace the config map and would recreate every sidebar handler. const environmentServerConfigs = appAtomRegistry.get(environmentServerConfigsAtom); const targetServerSettings = environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; const { getComposerDraft, getDraftSessionByLogicalProjectKey, getDraftSession, getDraftThread, applyStickyState, setDraftThreadContext, setLogicalProjectDraftThreadId, setModelSelection, setPrompt, } = useComposerDraftStore.getState(); // Never over text the user wrote: a draft can gain some while this awaits. const seedPrompt = (draftId: DraftId) => { if (options?.prompt === undefined) return; if (composerDraftHasUserContent(getComposerDraft(draftId))) return; setPrompt(draftId, options.prompt); }; const requestingRouteHref = router.state.location.href; const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref; const currentRouteTarget = getCurrentRouteTarget(); const carrySourceShell = currentRouteTarget?.kind === "server" ? readThreadShell(currentRouteTarget.threadRef) : null; const carrySourceDraft = currentRouteTarget?.kind === "draft" ? getDraftSession(currentRouteTarget.draftId) : null; 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 carryInteractionMode = carrySourceComposer?.interactionMode ?? carrySourceShell?.interactionMode ?? carrySourceDraft?.interactionMode ?? null; const project = projects.find( (candidate) => candidate.id === projectRef.projectId && candidate.environmentId === projectRef.environmentId, ); const abandonedSinceRequest = () => routeChangedSinceRequest() || (project !== undefined && readProject(projectRef) === null); const projectSettings = resolveProjectSettings( targetServerSettings, project?.id ?? null, project, ); const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection; const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ projectDefaultSelection: projectDefaultModelSelection ?? null, carrySelection: carryModelSelection, carrySourceDraftId: currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null, destinationDraftId, }); const resolveDefaultEnvMode = async (): Promise => { // Environment queries wait for a connection, so reading the project file on an // offline machine would never settle and the draft would never open. const consultProjectFile = project !== undefined && projectSettings.settings.defaultThreadEnvMode === null && appAtomRegistry .get(environmentSummaries.connectedEnvironmentIdsAtom) .includes(project.environmentId); const projectFile = consultProjectFile ? await readT3ProjectFile(project.environmentId, project.workspaceRoot) : null; return resolveNewThreadEnvMode({ projectSettings: resolveProjectSettings( targetServerSettings, project?.id ?? null, project, projectFile, ), workspaceRoot: project?.workspaceRoot ?? null, newProjectsRoot: environmentServerConfigs.get(projectRef.environmentId)?.newProjectsRoot, projectHasThreads: readThreadShells().some( (thread) => thread.environmentId === projectRef.environmentId && thread.projectId === projectRef.projectId, ) || readUnloadedThreadCount( [{ environmentId: projectRef.environmentId, id: projectRef.projectId }], [], ) > 0, }); }; 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); } 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; let workspaceContext: NewThreadWorkspaceOptions | null = null; if (hasExplicitWorkspaceOption) { workspaceContext = pickExplicitWorkspaceOptions(options); } else if (!isDraftAlreadyOpen) { const defaultEnvMode = await resolveDefaultEnvMode(); if (abandonedSinceRequest()) { return null; } // Concurrent calls may change the draft; stale resets, remaps, or navigation could clobber it. 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: projectSettings.settings.newWorktreesStartFromOrigin, }), }; } if (workspaceContext) { setDraftThreadContext(emptyStoredDraftThread.draftId, { ...workspaceContext, ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); } const storedDraft = getComposerDraft(emptyStoredDraftThread.draftId); const storedDraftHasExplicitModelPick = hasExplicitComposerModelSelection(storedDraft); if (!storedDraftHasExplicitModelPick) { applyStickyState(emptyStoredDraftThread.draftId); const modelSelectionOverride = resolveModelSelectionOverride( emptyStoredDraftThread.draftId, ); if (modelSelectionOverride) { setModelSelection(emptyStoredDraftThread.draftId, modelSelectionOverride, { replaceOptions: true, }); } } // Remapping physical projects clears workspace context; preserve the branch and worktree. setLogicalProjectDraftThreadId( logicalProjectKey, projectRef, emptyStoredDraftThread.draftId, { threadId: emptyStoredDraftThread.threadId, ...workspaceContext, ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, ); const opened = { draftId: emptyStoredDraftThread.draftId, threadId: emptyStoredDraftThread.threadId, }; seedPrompt(emptyStoredDraftThread.draftId); 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 && !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), }); seedPrompt(currentRouteTarget.draftId); 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()); if (abandonedSinceRequest()) { return null; } // Reuse drafts registered while awaiting so their pending navigation cannot lose them. const racedDraft = getDraftSessionByLogicalProjectKey(logicalProjectKey); if ( racedDraft && racedDraft.draftId !== storedDraftThread?.draftId && readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null ) { setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, racedDraft.draftId, { threadId: racedDraft.threadId, createdAt: racedDraft.createdAt, runtimeMode: racedDraft.runtimeMode, interactionMode: racedDraft.interactionMode, ...pickExplicitWorkspaceOptions(options), }); seedPrompt(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: projectSettings.settings.newWorktreesStartFromOrigin, }), runtimeMode: defaultRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); applyStickyState(draftId); const modelSelectionOverride = resolveModelSelectionOverride(draftId); if (modelSelectionOverride) { setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); } seedPrompt(draftId); await router.navigate({ to: "/draft/$draftId", params: { draftId }, replace: options?.replace ?? false, }); return { draftId, threadId }; })(); }; } export function resolveDefaultProjectRef( projects: ReadonlyArray, projectOrder: readonly string[], ): ScopedProjectRef | null { const [defaultProject] = orderItemsByPreferredIds({ items: projects, preferredIds: projectOrder, getId: getProjectOrderKey, getPreferenceIds: (project) => [ getProjectOrderKey(project), legacyProjectCwdPreferenceKey(project.workspaceRoot), ], }); return defaultProject ? scopeProjectRef(defaultProject.environmentId, defaultProject.id) : null; } export function useHandleNewThread() { const projectOrder = useUiStateStore((store) => store.projectOrder); const routeTarget = useThreadRouteTarget(); const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; const routeDraftId = routeTarget?.kind === "draft" ? routeTarget.draftId : null; const activeThread = useQuietThreadShell(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 handleNewThread = useNewThreadHandler(); const defaultProjectRef = resolveDefaultProjectRef(projects, projectOrder); return { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeDraftId, routeThreadRef, }; }