527 lines · 17.7 KB · TypeScript
RawDownload
import { Stack, useRouter } from "expo-router";import { useCallback, useEffect, useMemo, useRef } from "react";import { Alert, InteractionManager, View, useColorScheme } from "react-native";import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";import { useSafeAreaInsets } from "react-native-safe-area-context";import { useThemeColor } from "../../lib/useThemeColor"; import { EnvironmentId } from "@t3tools/contracts";import {  isAtomCommandInterrupted,  squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";import {  ComposerToolbarButton,  ComposerToolbarRow,  ComposerToolbarScroller,  ComposerToolbarTrigger,} from "../../components/ComposerToolbarTrigger";import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";import { ControlPillMenu } from "../../components/ControlPill";import { ProviderIcon } from "../../components/ProviderIcon"; import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";import {  applyProviderOptionMenuEvent,  buildProviderOptionMenuActions,  providerOptionsConfigurationLabel,  resolveProviderOptionDescriptors,} from "../../lib/providerOptions";import { buildThreadRoutePath } from "../../lib/routes";import { MOBILE_TYPOGRAPHY } from "../../lib/typography";import { useProjects } from "../../state/entities";import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";import { useCreateProjectThread } from "./use-project-actions"; function formatWorkspaceLabel(input: {  readonly workspaceMode: string;  readonly currentBranchName: string | null;  readonly selectedBranchName: string | null;}): string {  const branchName = input.selectedBranchName ?? input.currentBranchName;  if (input.workspaceMode === "worktree") {    return branchName ? `New worktree · ${branchName}` : "New worktree";  }  return branchName ? `Current · ${branchName}` : "Current checkout";} export function NewTaskDraftScreen(props: {  readonly initialProjectRef?: {    readonly environmentId?: string;    readonly projectId?: string;  };}) {  const projects = useProjects();  const createProjectThread = useCreateProjectThread();  const flow = useNewTaskFlow();  const router = useRouter();  const insets = useSafeAreaInsets();  const colorScheme = useColorScheme();  const isKeyboardVisible = useKeyboardState((state) => state.isVisible);  const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);  const { logicalProjects, selectedProject, setProject } = flow;  const promptInputRef = useRef<ComposerEditorHandle>(null);   const borderColor = useThemeColor("--color-border");  const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";  const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";   useEffect(() => {    if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) {      const directProject =        projects.find(          (project) =>            project.environmentId === props.initialProjectRef?.environmentId &&            project.id === props.initialProjectRef?.projectId,        ) ?? null;       if (directProject) {        setProject(directProject);        return;      }    }     if (selectedProject) {      return;    }     if (logicalProjects.length === 1) {      setProject(logicalProjects[0]!.project);      return;    }     router.replace("/new");  }, [    logicalProjects,    projects,    props.initialProjectRef?.environmentId,    props.initialProjectRef?.projectId,    router,    selectedProject,    setProject,  ]);   useEffect(() => {    if (!selectedProject) {      return;    }    void flow.loadBranches();  }, [flow, selectedProject]);   useEffect(() => {    if (!selectedProject) {      return;    }     let focusFrame: ReturnType<typeof requestAnimationFrame> | null = null;    const interaction = InteractionManager.runAfterInteractions(() => {      focusFrame = requestAnimationFrame(() => promptInputRef.current?.focus());    });     return () => {      interaction.cancel();      if (focusFrame !== null) {        cancelAnimationFrame(focusFrame);      }    };  }, [selectedProject]);   const environmentMenuActions = useMemo(    () =>      flow.environments.map((environment) => ({        id: `environment:${environment.environmentId}`,        title: environment.environmentLabel,        state:          flow.selectedEnvironmentId === environment.environmentId ? ("on" as const) : undefined,      })),    [flow.environments, flow.selectedEnvironmentId],  );   const modelMenuActions = useMemo(    () =>      flow.providerGroups.map((group) => ({        id: `provider:${group.providerKey}`,        title: group.providerLabel,        subtitle: group.models.find(          (model) =>            flow.selectedModel &&            model.selection.instanceId === flow.selectedModel.instanceId &&            model.selection.model === flow.selectedModel.model,        )?.label,        subactions: group.models.map((option) => ({          id: `model:${option.key}`,          title: option.label,          state:            flow.selectedModel &&            option.selection.instanceId === flow.selectedModel.instanceId &&            option.selection.model === flow.selectedModel.model              ? ("on" as const)              : undefined,        })),      })),    [flow.providerGroups, flow.selectedModel],  );  const providerOptionDescriptors = useMemo(    () =>      resolveProviderOptionDescriptors({        capabilities: flow.selectedModelOption?.capabilities,        selections: flow.selectedModel?.options,      }),    [flow.selectedModel?.options, flow.selectedModelOption?.capabilities],  );   const optionsMenuActions = useMemo(    () => [      ...buildProviderOptionMenuActions(providerOptionDescriptors),      {        id: "options-runtime",        title: "Runtime",        subtitle:          flow.runtimeMode === "approval-required"            ? "Approve actions"            : flow.runtimeMode === "auto-accept-edits"              ? "Auto-accept edits"              : "Full access",        subactions: [          { id: "options:runtime:approval-required", title: "Approve actions" },          { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" },          { id: "options:runtime:full-access", title: "Full access" },        ].map((option) => {          const value = option.id.replace("options:runtime:", "");          return {            id: option.id,            title: option.title,            state: flow.runtimeMode === value ? ("on" as const) : undefined,          };        }),      },      {        id: "options-interaction",        title: "Interaction",        subtitle: flow.interactionMode === "plan" ? "Plan" : "Default",        subactions: [          { id: "options:interaction:default", title: "Default" },          { id: "options:interaction:plan", title: "Plan" },        ].map((option) => {          const value = option.id.replace("options:interaction:", "");          return {            id: option.id,            title: option.title,            state: flow.interactionMode === value ? ("on" as const) : undefined,          };        }),      },    ],    [flow.interactionMode, flow.runtimeMode, providerOptionDescriptors],  );   const workspaceMenuActions = useMemo(() => {    const branchActions =      flow.availableBranches.length === 0        ? [            {              id: "workspace:branch:none",              title: flow.branchesLoading ? "Loading branches…" : "No branches available",              attributes: { disabled: true },            },          ]        : flow.availableBranches.slice(0, 12).map((branch) => {            const badge = branchBadgeLabel({              branch,              project: flow.selectedProject,            });             return {              id: `workspace:branch:${branch.name}`,              title: branch.name,              subtitle: badge ? badge.toUpperCase() : undefined,              state: flow.selectedBranchName === branch.name ? ("on" as const) : undefined,            };          });     return [      {        id: "workspace:mode",        title: "Mode",        subtitle: flow.workspaceMode === "local" ? "Current checkout" : "New worktree",        subactions: (["local", "worktree"] as const).map((value) => ({          id: `workspace:mode:${value}`,          title: value === "local" ? "Current checkout" : "New worktree",          state: flow.workspaceMode === value ? ("on" as const) : undefined,        })),      },      {        id: "workspace:branch",        title: "Branch",        subtitle: flow.selectedBranchName ?? "Choose branch",        subactions: branchActions,      },    ];  }, [    flow.availableBranches,    flow.branchesLoading,    flow.selectedBranchName,    flow.selectedProject,    flow.workspaceMode,  ]);   const selectedEnvironmentLabel =    flow.environments.find(      (environment) => environment.environmentId === flow.selectedEnvironmentId,    )?.environmentLabel ?? "Environment";  const currentBranchName =    flow.availableBranches.find((branch) => branch.current)?.name ??    flow.availableBranches.find((branch) => branch.isDefault)?.name ??    null;  const configurationLabel = useMemo(    () => providerOptionsConfigurationLabel(providerOptionDescriptors),    [providerOptionDescriptors],  );  const workspaceLabel = useMemo(    () =>      formatWorkspaceLabel({        currentBranchName,        selectedBranchName: flow.selectedBranchName,        workspaceMode: flow.workspaceMode,      }),    [currentBranchName, flow.selectedBranchName, flow.workspaceMode],  );  function handleModelMenuAction(event: string) {    if (!event.startsWith("model:")) {      return;    }    // Defer state update so the native menu dismiss animation completes    // before re-rendering the menu actions (prevents submenu jump).    setTimeout(() => {      flow.setSelectedModelKey(event.slice("model:".length));    }, 150);  }   function handleEnvironmentMenuAction(event: string) {    if (!event.startsWith("environment:")) {      return;    }    flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length)));  }   function handleOptionsMenuAction(event: string) {    const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event);    if (providerOptions) {      flow.setSelectedModelOptions(providerOptions);      return;    }    if (event.startsWith("options:runtime:")) {      flow.setRuntimeMode(        event.slice("options:runtime:".length) as Parameters<typeof flow.setRuntimeMode>[0],      );      return;    }    if (event.startsWith("options:interaction:")) {      flow.setInteractionMode(        event.slice("options:interaction:".length) as Parameters<typeof flow.setInteractionMode>[0],      );    }  }   function handleWorkspaceMenuAction(event: string) {    if (event.startsWith("workspace:mode:")) {      flow.setWorkspaceMode(        event.slice("workspace:mode:".length) as Parameters<typeof flow.setWorkspaceMode>[0],      );      return;    }    if (event.startsWith("workspace:branch:")) {      const branchName = event.slice("workspace:branch:".length);      const branch = flow.availableBranches.find((candidate) => candidate.name === branchName);      if (branch) {        flow.selectBranch(branch);      }    }  }   async function handlePickImages(): Promise<void> {    const result = await pickComposerImages({ existingCount: flow.attachments.length });    if (result.images.length > 0) {      flow.appendAttachments(result.images);    }  }   const handleNativePasteImages = useCallback(    async (uris: ReadonlyArray<string>) => {      try {        const images = await convertPastedImagesToAttachments({          uris,          existingCount: flow.attachments.length,        });        if (images.length > 0) {          flow.appendAttachments(images);        }      } catch (error) {        console.error("[native paste] error converting images", error);      }    },    [flow],  );   async function handleStart(): Promise<void> {    if (      !flow.selectedProject ||      !flow.selectedModel ||      flow.prompt.trim().length === 0 ||      flow.submitting ||      (flow.workspaceMode === "worktree" && !flow.selectedBranchName)    ) {      return;    }     flow.setSubmitting(true);    const result = await createProjectThread({      project: flow.selectedProject,      modelSelection: flow.selectedModel,      envMode: flow.workspaceMode,      branch: flow.selectedBranchName,      worktreePath: flow.workspaceMode === "worktree" ? null : flow.selectedWorktreePath,      runtimeMode: flow.runtimeMode,      interactionMode: flow.interactionMode,      initialMessageText: flow.prompt.trim(),      initialAttachments: flow.attachments,    });    flow.setSubmitting(false);     if (result._tag === "Failure") {      if (!isAtomCommandInterrupted(result)) {        const error = squashAtomCommandFailure(result);        Alert.alert(          "Could not start task",          error instanceof Error ? error.message : "The task could not be started.",        );      }      return;    }     flow.setPrompt("");    flow.clearAttachments();    router.replace(buildThreadRoutePath(result.value));  }   if (!selectedProject) {    return (      <View className="flex-1 bg-sheet">        <Stack.Screen options={{ title: "Loading task" }} />      </View>    );  }   return (    <View className="flex-1 bg-sheet">      <Stack.Screen options={{ title: selectedProject.title }} />       <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}>        <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}>          <ComposerEditor            ref={promptInputRef}            autoFocus            multiline            scrollEnabled            value={flow.prompt}            skills={flow.selectedProviderSkills}            onChangeText={flow.setPrompt}            onPasteImages={(uris) => void handleNativePasteImages(uris)}            placeholder={`Describe a coding task in ${selectedProject.title}`}            style={{ flex: 1, minHeight: 0 }}            textStyle={MOBILE_TYPOGRAPHY.composer}          />        </View>         <View          style={{            borderTopWidth: 1,            borderTopColor: borderColor,            paddingBottom: controlsBottomPadding,          }}        >          {flow.attachments.length > 0 ? (            <View style={{ paddingHorizontal: 16, paddingTop: 12 }}>              <ComposerAttachmentStrip                attachments={flow.attachments}                onRemove={flow.removeAttachment}                imageSize={88}                imageBorderRadius={20}              />            </View>          ) : null}          <ComposerToolbarRow paddingBottom={controlsBottomPadding} paddingHorizontal={6}>            <ComposerToolbarScroller              fadeOpaque={sheetFadeOpaque}              fadeTransparent={sheetFadeTransparent}            >              <ComposerToolbarButton                icon="plus"                onPress={() => void handlePickImages()}                showChevron={false}              />              <ControlPillMenu                actions={modelMenuActions}                onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}              >                <ComposerToolbarTrigger                  accessibilityLabel="Model"                  iconNode={                    <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />                  }                  label={flow.selectedModelOption?.label ?? "Model"}                />              </ControlPillMenu>              <ControlPillMenu                actions={optionsMenuActions}                onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}              >                <ComposerToolbarTrigger                  accessibilityLabel="Configuration"                  icon="slider.horizontal.3"                  label={configurationLabel}                />              </ControlPillMenu>              <ControlPillMenu                actions={environmentMenuActions}                onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)}              >                <ComposerToolbarTrigger                  accessibilityLabel="Environment"                  icon="desktopcomputer"                  label={selectedEnvironmentLabel}                />              </ControlPillMenu>              <ControlPillMenu                actions={workspaceMenuActions}                onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)}              >

Lines 1–500 of 527.