1125 lines · 41.3 KB · TypeScript
RawDownload
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type {  EnvironmentId,  ModelSelection,  ProjectReadFileResult,  ProviderInteractionMode,  ProviderOptionSelection,  RuntimeMode,  ServerProviderSkill,} from "@t3tools/contracts";import {  CommandId,  DEFAULT_PROVIDER_INTERACTION_MODE,  DEFAULT_RUNTIME_MODE,  MessageId,  T3_PROJECT_FILE_NAME,  ThreadId,} from "@t3tools/contracts";import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";import {  isDefaultThreadEnvModeSettled,  resolveDefaultThreadEnvMode,} from "@t3tools/shared/threadEnvMode";import * as Arr from "effect/Array";import { pipe } from "effect/Function"; import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";import type { TurnCommandMetadata } from "../../lib/commandMetadata";import type { DraftComposerImageAttachment } from "../../lib/composerImages";import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";import {  buildModelOptions,  groupByProvider,  resolveDefaultableModelSelection,  resolveSelectableModelSelection,} from "../../lib/modelOptions";import { scopedProjectKey } from "../../lib/scopedEntities";import { appAtomRegistry } from "../../state/atom-registry";import { projectEnvironment } from "../../state/projects";import { useEnvironmentQuery } from "../../state/query";import {  appendComposerDraftAttachments,  clearComposerDraft,  copyComposerDraftContentIfEmpty,  getComposerDraftSnapshot,  isComposerDraftEmpty,  removeComposerDraftAttachment,  replaceComposerDraftAttachments,  setComposerDraftText,  updateComposerDraftSettings,  useComposerDraft,} from "../../state/use-composer-drafts";import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";import { vcsEnvironment } from "../../state/vcs";import {  flattenQueuedThreadMessages,  threadOutboxManager,  updateThreadOutboxMessage,  type QueuedThreadMessage,} from "../../state/thread-outbox";import {  holdEditingQueuedMessage,  releaseEditingQueuedMessage,  useThreadOutboxMessages,} from "../../state/use-thread-outbox";import {  setPendingConnectionError,  useSavedRemoteConnections,} from "../../state/use-remote-environment-registry";import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";import { type VcsRef } from "@t3tools/client-runtime/state/vcs";import {  buildHomeProjectScopes,  sortHomeProjectScopes,  type HomeProjectScope,} from "../home/homeThreadList";import { useMobileProjectGroupingSettings } from "../../state/project-grouping";import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode";import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";import {  resolveNewTaskBranchWorktreePath,  resolveNewTaskLocalWorkspaceSelection,} from "./new-task-context-presentation"; type WorkspaceMode = "local" | "worktree"; const BRANCH_SEARCH_DEBOUNCE_MS = 150; function pendingTaskDraftKey(messageId: string): string {  return `pending-task:${messageId}`;} // The message id owned by the currently active editing session, tracked// across provider instances. An in-flight flush from a dismissed session// consults it so it never drops the draft or releases the drain lock out from// under a newer session editing the same task.let activeEditingMessageId: string | null = null; function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null {  const message = flattenQueuedThreadMessages(    appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),  ).find((candidate) => candidate.messageId === messageId);  return message?.creation !== undefined ? message : null;} export function branchBadgeLabel(input: {  readonly branch: VcsRef;  readonly project: EnvironmentProject | null;}): string | null {  if (input.branch.current) {    return "current";  }  if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) {    return "worktree";  }  if (input.branch.isDefault) {    return "default";  }  return null;} type NewTaskFlowContextValue = {  readonly projectScopes: ReadonlyArray<HomeProjectScope>;  readonly selectedEnvironmentId: EnvironmentId | null;  readonly selectedProjectKey: string | null;  readonly selectedModelKey: string | null;  readonly workspaceMode: WorkspaceMode;  readonly selectedBranchName: string | null;  readonly selectedWorktreePath: string | null;  readonly startFromOrigin: boolean;  readonly draftKey: string | null;  readonly editingPendingTask: QueuedThreadMessage | null;  readonly prompt: string;  readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;  readonly submitting: boolean;  readonly branchQuery: string;  readonly branchesLoading: boolean;  readonly branchesError: string | null;  readonly branchesFetchingNextPage: boolean;  readonly hasMoreBranches: boolean;  readonly availableBranches: ReadonlyArray<VcsRef>;  readonly currentCheckoutBranchName: string | null;  readonly runtimeMode: RuntimeMode;  readonly interactionMode: ProviderInteractionMode;  readonly planModeEnabled: boolean;  readonly expandedProvider: string | null;  readonly environments: ReadonlyArray<{    readonly environmentId: EnvironmentId;    readonly environmentLabel: string;  }>;  readonly selectedProject: EnvironmentProject | null;  readonly modelOptions: ReadonlyArray<ModelOption>;  readonly selectedModel: ModelSelection | null;  readonly selectedModelOption: ModelOption | null;  readonly selectedProviderSkills: ReadonlyArray<ServerProviderSkill>;  readonly providerGroups: ReadonlyArray<ProviderGroup>;  readonly filteredBranches: ReadonlyArray<VcsRef>;  readonly reset: () => void;  readonly setProject: (project: EnvironmentProject) => void;  readonly selectEnvironment: (environmentId: EnvironmentId) => void;  readonly setSelectedModelKey: (    key: string | null,    options?: ReadonlyArray<ProviderOptionSelection>,  ) => void;  readonly setWorkspaceMode: (mode: WorkspaceMode) => void;  readonly selectBranch: (branch: VcsRef) => void;  readonly setStartFromOrigin: (value: boolean) => void;  readonly beginEditingPendingTask: (messageId: string) => boolean;  readonly finishEditingPendingTask: () => void;  readonly cancelEditingPendingTask: () => void;  readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null;  readonly setPrompt: (value: string) => void;  readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;  readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void;  readonly removeAttachment: (imageId: string) => void;  readonly clearAttachments: () => void;  readonly setSubmitting: (value: boolean) => void;  readonly setBranchQuery: (value: string) => void;  readonly loadBranches: () => void;  readonly loadMoreBranches: () => void;  readonly setRuntimeMode: (value: RuntimeMode) => void;  readonly setInteractionMode: (value: ProviderInteractionMode) => void;  readonly setSelectedModelOptions: (    value: ReadonlyArray<ProviderOptionSelection> | undefined,  ) => void;  readonly setExpandedProvider: (value: string | null) => void;}; const NewTaskFlowContext = React.createContext<NewTaskFlowContextValue | null>(null); export function NewTaskFlowProvider(props: React.PropsWithChildren) {  const projects = useProjects();  const threads = useThreadShells();  const { savedConnectionsById } = useSavedRemoteConnections();  const groupingSettings = useMobileProjectGroupingSettings();  const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState();  const projectScopes = useMemo(    () =>      sortHomeProjectScopes({        scopes: buildHomeProjectScopes({          projects,          environmentId: null,          projectGroupingMode: groupingSettings.sidebarProjectGroupingMode,        }),        threads,        pendingTasks: [],        projectSortOrder: "updated_at",      }),    [groupingSettings.sidebarProjectGroupingMode, projects, threads],  );   const [selectedEnvironmentIdOverride, setSelectedEnvironmentId] = useState<EnvironmentId | null>(    null,  );  const selectedEnvironmentId =    selectedEnvironmentIdOverride !== null &&    projects.some((project) => project.environmentId === selectedEnvironmentIdOverride)      ? selectedEnvironmentIdOverride      : (projects[0]?.environmentId ?? null);  const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);  const [submitting, setSubmitting] = useState(false);  const [branchQuery, setBranchQuery] = useState("");  const [expandedProvider, setExpandedProvider] = useState<string | null>(null);  const [editingPendingTask, setEditingPendingTask] = useState<QueuedThreadMessage | null>(null);  const pendingLocalBranchSyncDraftKeysRef = useRef(new Set<string>());  // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act  // on a task whose editing session already ended this render.  const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null);   const reset = useCallback(() => {    setSelectedEnvironmentId(null);    setSelectedProjectKey(null);    setSubmitting(false);    setBranchQuery("");    setExpandedProvider(null);    pendingLocalBranchSyncDraftKeysRef.current.clear();    const editing = editingPendingTaskRef.current;    editingPendingTaskRef.current = null;    setEditingPendingTask(null);    if (editing) {      if (activeEditingMessageId === editing.messageId) {        activeEditingMessageId = null;      }      releaseEditingQueuedMessage(editing.messageId);    }  }, []);   const projectsForEnvironment = useMemo(    () =>      pipe(        projects,        Arr.filter((project) => project.environmentId === selectedEnvironmentId),      ),    [projects, selectedEnvironmentId],  );   // Stand-in for the edited task's project while its shell is not loaded  // (environment offline / still synchronizing), built from the metadata  // snapshotted at enqueue time.  const editingPendingProject = useMemo<EnvironmentProject | null>(() => {    const creation = editingPendingTask?.creation;    if (!editingPendingTask || !creation) {      return null;    }    return {      environmentId: editingPendingTask.environmentId,      id: creation.projectId,      title: creation.projectTitle ?? "Unknown project",      // Deliberately empty when the snapshot has no cwd — downstream consumers      // (branch queries, worktree bootstrap) must skip it, not receive a      // fabricated path.      workspaceRoot: creation.projectCwd ?? "",      repositoryIdentity: null,      defaultModelSelection: editingPendingTask.modelSelection ?? null,      scripts: [],      createdAt: editingPendingTask.createdAt,      updatedAt: editingPendingTask.createdAt,    };  }, [editingPendingTask]);   const selectedProject =    projectsForEnvironment.find(      (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,    ) ??    // While editing a queued task whose project shell is absent, keep the task    // pinned to its own project — falling through to an arbitrary first    // project would silently retarget it (and its reused turn identifiers).    (editingPendingProject !== null &&    selectedProjectKey ===      scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id)      ? editingPendingProject      : (projectsForEnvironment[0] ?? null));   // Only offer machines that actually host the currently selected repository, so  // switching computers moves the same repo across machines instead of jumping to  // whatever unrelated project happens to be first on the other machine. Repository  // identity is the primary signal; projects that haven't reported one yet (still  // indexing) fall back to workspace basename / title so a valid host isn't hidden.  const selectedRepositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null;  // `|| null` (not `??`): a pending-task placeholder project can have an empty  // workspaceRoot, and an "" basename would reject every real host below.  const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;  const selectedProjectTitle = selectedProject?.title ?? null;  const environments = useMemo(() => {    const seen = new Set<EnvironmentId>();    const result: Array<{      readonly environmentId: EnvironmentId;      readonly environmentLabel: string;    }> = [];    const hostsSelectedRepository = (project: EnvironmentProject) => {      if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) {        return true;      }      const projectKey = project.repositoryIdentity?.canonicalKey ?? null;      if (selectedRepositoryKey !== null && projectKey !== null) {        return projectKey === selectedRepositoryKey;      }      return (        project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename ||        (selectedProjectTitle !== null && project.title === selectedProjectTitle)      );    };    for (const project of projects) {      if (!hostsSelectedRepository(project)) {        continue;      }      if (seen.has(project.environmentId)) {        continue;      }      const environment = savedConnectionsById[project.environmentId];      if (!environment) {        continue;      }      seen.add(project.environmentId);      result.push({        environmentId: project.environmentId,        environmentLabel: environment.environmentLabel,      });    }    return result;  }, [    projects,    savedConnectionsById,    selectedRepositoryKey,    selectedWorkspaceBasename,    selectedProjectTitle,  ]);   const selectedEnvironmentServerConfig = useEnvironmentServerConfig(    selectedProject?.environmentId ?? null,  );  // While a queued pending task is being edited its draft lives under a key  // scoped to the queued message, so per-project new-task drafts stay intact.  const selectedProjectDraftKey = editingPendingTask    ? pendingTaskDraftKey(editingPendingTask.messageId)    : selectedProject      ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`      : null;  const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);  const prompt = selectedProjectDraft.text;  const attachments = selectedProjectDraft.attachments;  // Default mode until the user picks one explicitly — same resolution web  // uses for new draft threads: per-project setting, then the repo's  // checked-in t3.json, then the server's configured default.  const t3ProjectFileQuery = useEnvironmentQuery(    selectedProject !== null && selectedProject.workspaceRoot !== ""      ? projectEnvironment.readFile({          environmentId: selectedProject.environmentId,          input: { cwd: selectedProject.workspaceRoot, relativePath: T3_PROJECT_FILE_NAME },        })      : null,  );  const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null;  const t3ProjectFileDefaultMode = useMemo(() => {    if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null;    return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null;  }, [t3ProjectFileData]);  const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({    projectSetting: selectedProject?.defaultThreadEnvMode,    projectFile: t3ProjectFileDefaultMode,    globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local",  });  // While unsettled the resolved default is provisional. Nothing may write  // it into the draft during that window (the auto-branch effect does), or  // the frozen interim value beats the t3.json default once it loads.  const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({    explicitMode: selectedProjectDraft.workspaceSelection?.mode,    projectSetting: selectedProject?.defaultThreadEnvMode,    projectFilePending: t3ProjectFileQuery.isPending,  });  const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;  const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;  const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;  // Keep the user's explicit choice separate from the resolved display value:  // only the explicit flag is ever written back to the draft, so the resolved  // value keeps tracking the server setting when the config loads late.  const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin;  const startFromOrigin =    draftStartFromOrigin ??    selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ??    true;  const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;  const interactionMode = planModeEnabled    ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE)    : DEFAULT_PROVIDER_INTERACTION_MODE;   // Stored selections only count while their provider is usable on the  // server; otherwise the server's default model wins instead of silently  // targeting a disabled provider. The draft selection is an explicit pick  // and passes through as-is; the project default (last used, possibly from  // desktop) is implicit and additionally never resolves to a legacy model.  const draftModelSelection = resolveSelectableModelSelection(    selectedEnvironmentServerConfig,    selectedProjectDraft.modelSelection ?? null,  );  const projectDefaultModelSelection = resolveDefaultableModelSelection(    selectedEnvironmentServerConfig,    selectedProject?.defaultModelSelection ?? null,  );  const modelOptions = useMemo(    () =>      buildModelOptions(        selectedEnvironmentServerConfig,        draftModelSelection ?? projectDefaultModelSelection,      ),    [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection],  );   const selectedModel =    draftModelSelection ??    projectDefaultModelSelection ??    modelOptions.find((option) => option.isDefault)?.selection ??    modelOptions[0]?.selection ??    null;  const selectedModelKey = selectedModel    ? `${selectedModel.instanceId}:${selectedModel.model}`    : null;   const selectedModelOption =    modelOptions.find(      (option) =>        selectedModel &&        option.selection.instanceId === selectedModel.instanceId &&        option.selection.model === selectedModel.model,    ) ?? null;  const selectedProviderSkills = useMemo(    () =>      selectedEnvironmentServerConfig?.providers.find(        (provider) => provider.instanceId === selectedModel?.instanceId,      )?.skills ?? [],    [selectedEnvironmentServerConfig, selectedModel?.instanceId],  );  const setSelectedModelKey = useCallback(    // Options ride along in the same write: a follow-up setSelectedModelOptions    // call would rebuild the selection from the stale pre-switch model.    (key: string | null, options?: ReadonlyArray<ProviderOptionSelection>) => {      if (!key || !selectedProjectDraftKey) {        return;      }      const option = modelOptions.find((candidate) => candidate.key === key);      if (!option) {        return;      }      updateComposerDraftSettings(selectedProjectDraftKey, {        modelSelection: options ? { ...option.selection, options } : option.selection,      });    },    [modelOptions, selectedProjectDraftKey],  );  const setSelectedModelOptions = useCallback(    (options: ReadonlyArray<ProviderOptionSelection> | undefined) => {      if (!selectedModel || !selectedProjectDraftKey) {        return;      }      const nextSelection: ModelSelection = options        ? { ...selectedModel, options }        : {            instanceId: selectedModel.instanceId,            model: selectedModel.model,          };      updateComposerDraftSettings(selectedProjectDraftKey, {        modelSelection: nextSelection,      });    },    [selectedModel, selectedProjectDraftKey],  );   const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);  const setPrompt = useCallback(    (value: string) => {      if (!selectedProjectDraftKey) {        return;      }      setComposerDraftText(selectedProjectDraftKey, value);    },    [selectedProjectDraftKey],  );  const replaceAttachments = useCallback(    (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => {

Lines 1–500 of 1125.