Repository: code-dot-org/code-dot-org
Pull request #64485: AI Chat: multimodal user upload UI
Author: login: sanchitmalhotra126
Created: 2025-03-12T01:01:10Z | Merged: 2025-03-14T18:01:21Z

Updates to the user-upload flow for multimodal AI Chat, which adds a "staged" area for files before submitting a chat request, along with other general UI updates. Overall UI is meant to be closer to pilot-ready (as per the spec), but I'm sure there will be a lot of follow-up tweaks to individual elements (e.g. margins, sizes, colors, final strings, icons etc).

I also added some initial support for a few error/warning cases: 1) file upload failed, 2) file limit exceeded, 3) file size limit exceeded for individual file. Currently these are configured to some defaults (5 files, 20MB per file), but we can change these as necessary. Also added logging for when uploads fail due to an unknown error (i.e. other than file size too big).

Video:

https://github.com/user-attachments/assets/fbaccdaa-bcbb-4e98-8ee6-ee6825fed2d7

Staged assets:
<img width="586" alt="Screenshot 2025-03-11 at 5 46 13 PM" src="https://github.com/user-attachments/assets/0c7f1be2-120f-44d8-844b-a069868bcd6d" />

Max file limit warning:
<img width="582" alt="Screenshot 2025-03-11 at 5 34 49 PM" src="https://github.com/user-attachments/assets/55ddad59-45ea-474d-ad5e-273ffb6e2ae6" />

File size exceeded error:
<img width="585" alt="Screenshot 2025-03-11 at 5 35 34 PM" src="https://github.com/user-attachments/assets/7001739a-f7a4-466f-b7f6-45ad3c38644f" />

General upload error:
<img width="585" alt="Screenshot 2025-03-11 at 5 35 21 PM" src="https://github.com/user-attachments/assets/d44ac78a-580e-438e-8b3e-2dc5709ba108" />


## Links

[Multimodal Spec](https://docs.google.com/document/d/13uPKipIkpoqF-ZrncZG4X0M7AhFY2rwXIVRCKxc2PPM/edit?tab=t.0#heading=h.qi8xxyq72bct)

## Testing story

Tested locally with allthethings level


Diff:
diff --git a/apps/i18n/aichat/en_us.json b/apps/i18n/aichat/en_us.json
index b9d1cb39e3019..ce211311a40c1 100644
--- a/apps/i18n/aichat/en_us.json
+++ b/apps/i18n/aichat/en_us.json
@@ -78,6 +78,7 @@
   "modelResetNotification": "Model customizations and model card information have been reset to default settings.",
   "modelUpdateText": "{fieldLabel} has been updated to {updatedText}. {timestamp}",
   "modelUpdateText2": "{fieldLabel} has been updated. {timestamp}",
+  "remove": "Remove",
   "retrievalCustomizationHeader": "Retrieval",
   "retrievalCustomizationTooltipText": "Retrieval lets you add new information for a chatbot to reference. Type in each retrieval statement into the text box, then click “Add” for each one.",
   "saveChangesReminderAlert": "Remember to save your changes",
@@ -99,6 +100,10 @@
   "technicalInfoHeader_temperature": "Temperature",
   "technicalInfoHeader_retrievalUsed": "Retrieval Used",
   "testStudentModel": "Test student model",
+  "upload": "Upload",
+  "uploadFailedAlert": "There was an error uploading one or more of your files. Please try again.",
+  "uploadFileLimitExceededAlert": "AI Chat supports a maximum of {maximum} files. Only some of your files were uploaded.",
+  "uploadSizeLimitExceededAlert": "AI Chat supports a maximum file size of {maximum}MB. Please try a smaller file.",
   "userViewButtonText": "User View",
   "viewStudentChatHistory": "{selectedStudentName}'s chat history",
   "viewOnlyTabLabel": "{fieldLabel} (view only)",
diff --git a/apps/package.json b/apps/package.json
index 98397b822f6da..617c9d5cab9c5 100644
--- a/apps/package.json
+++ b/apps/package.json
@@ -306,7 +306,6 @@
     "react-dom": "^17.0.2",
     "react-dom-confetti": "^0.2.0",
     "react-draggable": "^4.4.6",
-    "react-dropzone": "^14.3.8",
     "react-focus-on": "^3.9.4",
     "react-google-charts": "2",
     "react-idle-timer": "^4.2.7",
diff --git a/apps/src/aichat/constants.ts b/apps/src/aichat/constants.ts
index 888cc9e93703c..50dda5affb21a 100644
--- a/apps/src/aichat/constants.ts
+++ b/apps/src/aichat/constants.ts
@@ -29,3 +29,9 @@ export enum ModalTypes {
   WARNING = 'warning',
   TEACHER_ONBOARDING = 'teacherOnboarding',
 }
+
+// Maximum number of files that can be attached to a chat message in multimodal mode.
+export const MAX_NUM_FILES = 5;
+export const MAX_FILE_SIZE_MB = 20;
+// Allowed file types for upload in multimodal mode.
+export const ACCEPTED_FILE_TYPES = ['.jpg', '.jpeg', '.png', '.pdf'];
diff --git a/apps/src/aichat/redux/index.ts b/apps/src/aichat/redux/index.ts
index d19baa1f65a5c..6599967e032e0 100644
--- a/apps/src/aichat/redux/index.ts
+++ b/apps/src/aichat/redux/index.ts
@@ -15,4 +15,9 @@ export {
   setStudentChatHistory,
   setUserHasAichatAccess,
   setViewMode,
+  addStagedFile,
+  stagedFileUploadFinished,
+  removeStagedFile,
+  stagedFilesLimitExceeded,
+  clearStagedFilesAlert,
 } from './slice';
diff --git a/apps/src/aichat/redux/selectors/index.ts b/apps/src/aichat/redux/selectors/index.ts
index a507e929a9583..e63362400473e 100644
--- a/apps/src/aichat/redux/selectors/index.ts
+++ b/apps/src/aichat/redux/selectors/index.ts
@@ -1,6 +1,8 @@
 import {createSelector} from '@reduxjs/toolkit';
 
+import {queryParams} from '@cdo/apps/code-studio/utils';
 import type {RootState} from '@cdo/apps/types/redux';
+import {AiChatModelIds} from '@cdo/generated-scripts/sharedConstants';
 
 import {
   allFieldsHidden,
@@ -45,3 +47,10 @@ export const selectHavePropertiesChanged = (state: RootState) =>
     state.aichat.savedAiCustomizations,
     state.aichat.currentAiCustomizations
   ).length > 0;
+
+export const selectMultimodalEnabled = (state: RootState) => {
+  const isChatGpt =
+    state.aichat.currentAiCustomizations.selectedModelId ===
+    AiChatModelIds.CHATGPT;
+  return isChatGpt && queryParams('multimodal') === 'true';
+};
diff --git a/apps/src/aichat/redux/slice.ts b/apps/src/aichat/redux/slice.ts
index fffccabf53acf..1a7eb97db38e1 100644
--- a/apps/src/aichat/redux/slice.ts
+++ b/apps/src/aichat/redux/slice.ts
@@ -40,6 +40,8 @@ const initialState: AichatState = {
   saveInProgress: false,
   currentSaveType: undefined,
   userHasAichatAccess: false,
+  stagedFiles: [],
+  stagedFilesAlert: undefined,
 };
 
 const aichatSlice = createSlice({
@@ -215,6 +217,50 @@ const aichatSlice = createSlice({
       state.saveInProgress = false;
       state.currentSaveType = undefined;
     },
+    addStagedFile(
+      state,
+      action: PayloadAction<{key: string; filename: string}>
+    ) {
+      state.stagedFiles.push({
+        ...action.payload,
+        status: 'uploading',
+      });
+    },
+    stagedFileUploadFinished(
+      state,
+      action: PayloadAction<{
+        key: string;
+        status: 'uploaded' | 'uploadFailed' | 'sizeLimitExceeded';
+      }>
+    ) {
+      const {key, status} = action.payload;
+      if (status === 'uploaded') {
+        const fileIndex = state.stagedFiles.findIndex(file => file.key === key);
+        if (fileIndex !== -1) {
+          state.stagedFiles[fileIndex].status = 'uploaded';
+        }
+      } else {
+        // Remove from staged files and set alert
+        state.stagedFiles = state.stagedFiles.filter(file => file.key !== key);
+        state.stagedFilesAlert = status;
+      }
+    },
+    stagedFilesLimitExceeded(state) {
+      state.stagedFilesAlert = 'fileLimitExceeded';
+    },
+    clearStagedFilesAlert(state) {
+      state.stagedFilesAlert = undefined;
+    },
+    removeStagedFile(state, action: PayloadAction<string>) {
+      state.stagedFiles = state.stagedFiles.filter(
+        file => file.key !== action.payload
+      );
+      state.stagedFilesAlert = undefined;
+    },
+    clearStagedFiles(state) {
+      state.stagedFiles = [];
+      state.stagedFilesAlert = undefined;
+    },
   },
 });
 
@@ -263,4 +309,10 @@ export const {
   setStudentChatHistory,
   setUserHasAichatAccess,
   setViewMode,
+  addStagedFile,
+  stagedFileUploadFinished,
+  removeStagedFile,
+  clearStagedFiles,
+  stagedFilesLimitExceeded,
+  clearStagedFilesAlert,
 } = aichatSlice.actions;
diff --git a/apps/src/aichat/redux/state.ts b/apps/src/aichat/redux/state.ts
index 4b82ac0769524..b2762c1e09d97 100644
--- a/apps/src/aichat/redux/state.ts
+++ b/apps/src/aichat/redux/state.ts
@@ -31,4 +31,16 @@ export interface AichatState {
   // The type of save action being performed (customization update, publish, model card save, etc).
   currentSaveType: SaveType | undefined;
   userHasAichatAccess: boolean;
+  // List of files that have been staged for upload to the model.
+  stagedFiles: {
+    key: string;
+    filename: string;
+    status: 'uploading' | 'uploaded';
+  }[];
+  // Alert to display for staged files if something went wrong.
+  stagedFilesAlert:
+    | 'uploadFailed'
+    | 'fileLimitExceeded'
+    | 'sizeLimitExceeded'
+    | undefined;
 }
diff --git a/apps/src/aichat/redux/thunks/submitChatContents.ts b/apps/src/aichat/redux/thunks/submitChatContents.ts
index 8f81cf135cd50..857bddbc9606e 100644
--- a/apps/src/aichat/redux/thunks/submitChatContents.ts
+++ b/apps/src/aichat/redux/thunks/submitChatContents.ts
@@ -2,6 +2,7 @@ import {createAsyncThunk} from '@reduxjs/toolkit';
 
 import {
   clearChatMessagePending,
+  clearStagedFiles,
   setChatMessagePending,
 } from '@cdo/apps/aichat/redux/slice';
 import {Role} from '@cdo/apps/aiComponentLibrary/chatMessage/types';
@@ -39,6 +40,9 @@ export const submitChatContents = createAsyncThunk(
     const {savedAiCustomizations: aiCustomizations, chatEventsCurrent} =
       state.aichat;
 
+    // Clear any staged files if present (used with multimodal models)
+    thunkAPI.dispatch(clearStagedFiles());
+
     const aichatContext: AichatContext = {
       currentLevelId: parseInt(state.progress.currentLevelId || ''),
       scriptId: state.progress.scriptId,
diff --git a/apps/src/aichat/views/ChatWorkspace.tsx b/apps/src/aichat/views/ChatWorkspace.tsx
index 7c721407d02ea..676a62822533d 100644
--- a/apps/src/aichat/views/ChatWorkspace.tsx
+++ b/apps/src/aichat/views/ChatWorkspace.tsx
@@ -14,10 +14,13 @@ import aichatI18n from '../locale';
 import {
   fetchStudentChatHistory,
   selectAllVisibleMessages,
+  selectMultimodalEnabled,
   setShowModalType,
 } from '../redux';
 import {getShortName} from '../utils';
 
+import StagedFilesPreview from './assets/StagedFilesPreview';
+import UploadButton from './assets/UploadButton';
 import ChatEventsList from './ChatEventsList';
 import CopyChatHistoryButton from './CopyChatHistoryButton';
 import UserChatMessageEditor from './UserChatMessageEditor';
@@ -170,6 +173,8 @@ const ChatWorkspace: React.FunctionComponent<ChatWorkspaceProps> = ({
     dispatch(setShowModalType(undefined));
   }, [dispatch, isUserTeacher, showModalType]);
 
+  const multimodalEnabled = useAppSelector(selectMultimodalEnabled);
+
   return (
     <div id="chat-workspace-area" className={moduleStyles.chatWorkspace}>
       {ChatModal && <ChatModal onClose={onCloseModal} />}
@@ -180,12 +185,14 @@ const ChatWorkspace: React.FunctionComponent<ChatWorkspaceProps> = ({
       )}
 
       <div className={moduleStyles.footer}>
+        {multimodalEnabled && <StagedFilesPreview />}
         {canChatWithModel && (
           <UserChatMessageEditor
             editorContainerClassName={moduleStyles.messageEditorContainer}
           />
         )}
         <div className={moduleStyles.buttonRow}>
+          {multimodalEnabled && <UploadButton />}
           <Button
             text={aichatI18n.clearChatButtonText()}
             disabled={!canChatWithModel}
diff --git a/apps/src/aichat/views/UserChatMessageEditor.tsx b/apps/src/aichat/views/UserChatMessageEditor.tsx
index 51fb3aa73cd9b..11147f489576a 100644
--- a/apps/src/aichat/views/UserChatMessageEditor.tsx
+++ b/apps/src/aichat/views/UserChatMessageEditor.tsx
@@ -1,24 +1,9 @@
-import {Button} from '@code-dot-org/component-library/button';
-import {FontAwesomeV6IconProps} from '@code-dot-org/component-library/fontAwesomeV6Icon';
-import React, {
-  RefObject,
-  useCallback,
-  useEffect,
-  useRef,
-  useState,
-} from 'react';
-import {useDropzone} from 'react-dropzone';
+import React, {useCallback, useEffect, useRef} from 'react';
 
 import UserMessageEditor from '@cdo/apps/aiComponentLibrary/userMessageEditor/UserMessageEditor';
-import {queryParams} from '@cdo/apps/code-studio/utils';
-import HttpClient from '@cdo/apps/util/HttpClient';
 import {useAppDispatch, useAppSelector} from '@cdo/apps/util/reduxHooks';
-import {AiChatModelIds} from '@cdo/generated-scripts/sharedConstants';
 
-import {submitChatContents} from '../redux';
-import {UploadAssetResponse} from '../types';
-
-import moduleStyles from './chatWorkspace.module.scss';
+import {selectMultimodalEnabled, submitChatContents} from '../redux';
 
 /**
  * Renders the AI Chat Lab user chat message editor component.
@@ -31,6 +16,13 @@ const UserChatMessageEditor: React.FunctionComponent<{
   );
 
   const saveInProgress = useAppSelector(state => state.aichat.saveInProgress);
+  const multimodalEnabled = useAppSelector(selectMultimodalEnabled);
+  const stagedFilenames = useAppSelector(state =>
+    state.aichat.stagedFiles.map(file => file.filename)
+  );
+  const uploadsPending = useAppSelector(state =>
+    state.aichat.stagedFiles.some(file => file.status === 'uploading')
+  );
 
   const dispatch = useAppDispatch();
 
@@ -39,13 +31,21 @@ const UserChatMessageEditor: React.FunctionComponent<{
   const handleSubmit = useCallback(
     (userMessage: string) => {
       if (!isWaitingForChatResponse) {
-        dispatch(submitChatContents({text: userMessage}));
+        dispatch(
+          submitChatContents({
+            text: userMessage,
+            assets:
+              multimodalEnabled && stagedFilenames.length > 0
+                ? stagedFilenames
+                : undefined,
+          })
+        );
       }
     },
-    [isWaitingForChatResponse, dispatch]
+    [isWaitingForChatResponse, multimodalEnabled, stagedFilenames, dispatch]
   );
 
-  const disabled = isWaitingForChatResponse || saveInProgress;
+  const disabled = isWaitingForChatResponse || saveInProgress || uploadsPending;
 
   useEffect(() => {
     if (!disabled) {
@@ -55,25 +55,6 @@ const UserChatMessageEditor: React.FunctionComponent<{
     }
   }, [disabled]);
 
-  const isChatGpt = useAppSelector(
-    state =>
-      state.aichat.currentAiCustomizations.selectedModelId ===
-      AiChatModelIds.CHATGPT
-  );
-  const multimodalEnabled = isChatGpt && queryParams('multimodal') === 'true';
-  if (multimodalEnabled) {
-    return (
-      <UploadButton inputRef={inputRef}>
-        <UserMessageEditor
-          onSubmit={handleSubmit}
-          disabled={disabled}
-          editorContainerClassName={editorContainerClassName}
-          ref={inputRef}
-        />
-      </UploadButton>
-    );
-  }
-
   return (
     <UserMessageEditor
       onSubmit={handleSubmit}
@@ -84,74 +65,4 @@ const UserChatMessageEditor: React.FunctionComponent<{
   );
 };
 
-const plusIcon: FontAwesomeV6IconProps = {
-  iconName: 'plus',
-};
-const spinIcon: FontAwesomeV6IconProps = {
-  iconName: 'spinner',
-  animationType: 'spin',
-};
-
-/** Upload button for adding assets to current chat message. UI is not final (hidden behind URL param) */
-interface UploadProps {
-  inputRef: RefObject<HTMLTextAreaElement>;
-  children: React.ReactNode;
-}
-const UploadButton: React.FC<UploadProps> = ({inputRef, children}) => {
-  const dispatch = useAppDispatch();
-  const currentChannelId = useAppSelector(state => state.lab.channel?.id);
-  const [loading, setLoading] = useState(false);
-  const onDrop = useCallback(
-    async (acceptedFiles: File[]) => {
-      if (currentChannelId) {
-        setLoading(true);
-        const assets: string[] = [];
-        // Cap at 3 for now
-        for (const file of acceptedFiles.slice(0, 3)) {
-          const response = await HttpClient.put(
-            `/v3/assets/${currentChannelId}/${file.name}`,
-            file
-          );
-          const {filename} = (await response.json()) as UploadAssetResponse;
-          assets.push(filename);
-        }
-
-        // Temp solution: grab the input value from the ref and submit when choosing assets.
-        dispatch(
-          submitChatContents({
-            text: inputRef.current?.value || '',
-            assets,
-          })
-        );
-        setLoading(false);
-      }
-    },
-    [currentChannelId, dispatch, inputRef]
-  );
-  const {getRootProps, getInputProps, isDragActive} = useDropzone({
-    onDrop,
-    accept: {
-      'image/*': ['.jpeg', '.jpg', '.png'],
-      'application/pdf': ['.pdf'],
-    },
-  });
-  return (
-    <div className={moduleStyles.messageEditorRow}>
-      <div {...getRootProps()}>
-        <input {...getInputProps()} />
-        <Button
-          disabled={loading}
-          isIconOnly={true}
-          icon={loading ? spinIcon : plusIcon}
-          size="m"
-          type="secondary"
-          color={isDragActive ? 'black' : 'gray'}
-          onClick={() => {}}
-        />
-      </div>
-      {children}
-    </div>
-  );
-};
-
 export default UserChatMessageEditor;
diff --git a/apps/src/aichat/views/assets/StagedFilesPreview.tsx b/apps/src/aichat/views/assets/StagedFilesPreview.tsx
new file mode 100644
index 0000000000000..bc225e188856a
--- /dev/null
+++ b/apps/src/aichat/views/assets/StagedFilesPreview.tsx
@@ -0,0 +1,134 @@
+import Alert from '@code-dot-org/component-library/alert';
+import FontAwesomeV6Icon from '@code-dot-org/component-library/fontAwesomeV6Icon';
+import {WithTooltip} from '@code-dot-org/component-library/tooltip';
+import {StrongText} from '@code-dot-org/component-library/typography';
+import classNames from 'classnames';
+import React from 'react';
+
+import {useAppDispatch, useAppSelector} from '@cdo/apps/util/reduxHooks';
+
+import {MAX_FILE_SIZE_MB, MAX_NUM_FILES} from '../../constants';
+import aichatI18n from '../../locale';
+import {clearStagedFilesAlert, removeStagedFile} from '../../redux';
+
+import styles from './staged-files-preview.module.scss';
+
+const alerts = {
+  uploadFailed: [aichatI18n.uploadFailedAlert(), 'danger'] as const,
+  fileLimitExceeded: [
+    aichatI18n.uploadFileLimitExceededAlert({maximum: MAX_NUM_FILES}),
+    'warning',
+  ] as const,
+  sizeLimitExceeded: [
+    aichatI18n.uploadSizeLimitExceededAlert({maximum: MAX_FILE_SIZE_MB}),
+    'danger',
+  ] as const,
+};
+
+const StagedFilesPreview: React.FC = () => {
+  const dispatch = useAppDispatch();
+  const stagedFiles = useAppSelector(state => state.aichat.stagedFiles);
+  const currentChannelId = useAppSelector(state => state.lab.channel?.id);
+
+  const [alertMessage, style] = useAppSelector(state => {
+    if (!state.aichat.stagedFilesAlert) {
+      return [];
+    }
+
+    return alerts[state.aichat.stagedFilesAlert];
+  });
+
+  if (stagedFiles.length === 0 && !alertMessage) {
+    return null;
+  }
+
+  return (
+    <div className={styles.container}>
+      <div className={styles.row}>
+        {stagedFiles.map(({key, filename, status}) => {
+          return (
+            <FilePreview
+              key={key}
+              type={filename.endsWith('.pdf') ? 'pdf' : 'image'}
+              url={`/v3/assets/${currentChannelId}/${encodeURIComponent(
+                filename
+              )}`}
+              filename={filename}
+              isLoading={status === 'uploading'}
+              onRemove={() => dispatch(removeStagedFile(key))}
+            />
+          );
+        })}
+      </div>
+      {alertMessage && style && (
+        <Alert
+          type={style}
+          text={alertMessage}
+          size="xs"
+          onClose={() => dispatch(clearStagedFilesAlert())}
+        />
+      )}
+    </div>
+  );
+};
+
+const FilePreview: React.FC<{
+  type: 'pdf' | 'image';
+  filename: string;
+  url: string;
+  isLoading: boolean;
+  onRemove: () => void;
+}> = ({type, filename, url, isLoading, onRemove}) => {
+  return (
+    <div
+      className={styles[`preview-${type}`]}
+      title={filename}
+      style={
+        !isLoading && type === 'image' ? {backgroundImage: `url('${url}')`} : {}
+      }
+    >
+      {isLoading ? (
+        <FontAwesomeV6Icon
+          className={styles.topRightIcon}
+          iconName={'circle-notch'}
+          animationType={'spin'}
+        />
+      ) : (
+        <WithTooltip
+          tooltipProps={{
+            tooltipId: 'close-button',
+            direction: 'onTop',
+            size: 'xs',
+            text: aichatI18n.remove(),
+            className: styles.closeTooltip,
+          }}
+          tooltipOverlayClassName={styles.closeTooltipOverlay}
+        >
+          <button
+            className={classNames(
+              styles.topRightIcon,
+              styles.topRightIconClose
+            )}
+            type="button"
+            onClick={onRemove}
+          >
+            <FontAwesomeV6Icon iconName={'circle-xmark'} />
+          </button>
+        </WithTooltip>
+      )}
+      {type === 'pdf' && (
+        <>
+          <div className={styles.fileIcon}>
+            <FontAwesomeV6Icon iconName="file" />
+          </div>
+          <div className={styles.filenameContainer}>
+            <StrongText>{filename}</StrongText>
+            <span>PDF</span>
+          </div>
+        </>
+      )}
+    </div>
+  );
+};
+
+export default StagedFilesPreview;
diff --git a/apps/src/aichat/views/assets/UploadButton.tsx b/apps/src/aichat/views/assets/UploadButton.tsx
new file mode 100644
index 0000000000000..bd7c1a68ef295
--- /dev/null
+++ b/apps/src/aichat/views/assets/UploadButton.tsx
@@ -0,0 +1,116 @@
+import {Button} from '@code-dot-org/component-library/button';
+import React, {ChangeEvent, useCallback, useRef} from 'react';
+
+import Lab2Registry from '@cdo/apps/lab2/Lab2Registry';
+import HttpClient, {NetworkError} from '@cdo/apps/util/HttpClient';
+import {useAppDispatch, useAppSelector} from '@cdo/apps/util/reduxHooks';
+
+import {ACCEPTED_FILE_TYPES, MAX_NUM_FILES} from '../../constants';
+import aichatI18n from '../../locale';
+import {
+  addStagedFile,
+  clearStagedFilesAlert,
+  stagedFilesLimitExceeded,
+  stagedFileUploadFinished,
+} from '../../redux';
+
+const UploadButton: React.FC = () => {
+  const dispatch = useAppDispatch();
+  const inputRef = useRef<HTMLInputElement>(null);
+  const currentChannelId = useAppSelector(state => state.lab.channel?.id);
+  const numStagedFiles = useAppSelector(
+    state => state.aichat.stagedFiles.length
+  );
+
+  const onSelectFile = async (event: ChangeEvent<HTMLInputElement>) => {
+    const files = event.target.files;
+    if (!files) {
+      return;
+    }
+
+    // Clear the alert, if any
+    dispatch(clearStagedFilesAlert());
+
+    const numAllowedFiles = MAX_NUM_FILES - numStagedFiles;
+
+    if (files.length > numAllowedFiles) {
+      dispatch(stagedFilesLimitExceeded());
+    }
+
+    const allowedFiles = Array.from(files)
+      .slice(0, numAllowedFiles)
+      .map<[string, string, File]>(file => [
+        // Create a unique key for each upload in case the same file is uploaded more than once.
+        `${file.name}-${Date.now()}`,
+        file.name,
+        file,
+      ]);
+
+    for (const [key, filename] of allowedFiles) {
+      dispatch(addStagedFile({key, filename}));
+    }
+
+    for (const [key, filename, file] of allowedFiles) {
+      try {
+        await HttpClient.put(
+          `/v3/assets/${currentChannelId}/${encodeURIComponent(filename)}`,
+          file
+        );
+        dispatch(stagedFileUploadFinished({key, status: 'uploaded'}));
+      } catch (error) {
+        let status: 'sizeLimitExceeded' | 'uploadFailed' = 'uploadFailed';
+        if (error instanceof NetworkError && error.response.status === 413) {
+          status = 'sizeLimitExceeded';
+        } else {
+          status = 'uploadFailed';
+          // Only log if not a size limit exceeded error
+          Lab2Registry.getInstance()
+            .getMetricsReporter()
+            .logError('Error uploading asset', error as Error, {
+              filename: file.name,
+            });
+        }
+
+        dispatch(
+          stagedFileUploadFinished({
+            key,
+            status,
+          })
+        );
+      }
+    }
+  };
+
+  const onUploadClick = useCallback(() => {
+    if (inputRef.current) {
+      inputRef.current.click();
+      // Clear the value in case the same file is selected again.
+      inputRef.current.value = '';
+    }
+  }, [inputRef]);
+
+  return (
+    <>
+      <input
+        type="file"
+        id="file"
+        ref={inputRef}
+        style={{display: 'none'}}
+        onChange={onSelectFile}
+        accept={ACCEPTED_FILE_TYPES.join(',')}
+        multiple
+      />
+      <Button
+        text={aichatI18n.upload()}
+        iconLeft={{iconName: 'upload'}}
+        size="s"
+        type="secondary"
+        color="gray"
+        onClick={onUploadClick}
+        disabled={numStagedFiles >= MAX_NUM_FILES}
+      />
+    </>
+  );
+};
+
+export default UploadButton;
diff --git a/apps/src/aichat/views/assets/staged-files-preview.module.scss b/apps/src/aichat/views/assets/staged-files-preview.module.scss
new file mode 100644
index 0000000000000..065c58bdd939a
--- /dev/null
+++ b/apps/src/aichat/views/assets/staged-files-preview.module.scss
@@ -0,0 +1,99 @@
+@import '@code-dot-org/component-library-styles/colors.scss';
+@import '../../../mixins.scss';
+@import '../mixins.scss';
+
+.container {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  padding: 0 10px;
+}
+
+.row {
+  display: flex;
+  gap: 8px;
+  overflow-x: auto;
+  padding-top: 8px;
+  padding-bottom: 4px;
+}
+
+%preview {
+  border: 1px solid var(--neutral-gray-20);
+  border-radius: 4px;
+  padding: 8px;
+  position: relative;
+}
+
+.topRightIcon {
+  @include remove-button-styles;
+  z-index: 1;
+  font-size: 16px;
+  position: absolute;
+  right: -8px;
+  top: -8px;
+  color: var(--text-neutral-quaternary);
+  transition: opacity 0.2s ease, color 0.2s ease;
+
+  i {
+    background-color: white;
+    border-radius: 100%;
+  }
+
+  &Close {
+    color: var(--text-neutral-primary);
+    &:hover {
+      color: var(--text-error-primary);
+    }
+  }
+}
+
+.closeTooltipOverlay {
+  position: absolute;
+  top: 0;
+  right: 0;
+}
+
+.closeTooltip {
+  @include fade-in(0.3s);
+}
+
+.preview-pdf {
+  @extend %preview;
+  display: flex;
+  gap: 8px;
+
+  .fileIcon {
+    display: flex;
+    width: 36px;
+    height: 36px;
+    border-radius: 4px;
+    background-color: var(--accent-strawberry-40);
+
+    i {
+      margin: auto;
+      font-size: 24px;
+      color: white;
+    }
+  }
+
+  .filenameContainer {
+    display: flex;
+    flex-direction: column;
+    max-width: 120px;
+
+    strong {
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+  }
+}
+
+.preview-image {
+  @extend %preview;
+  height: 36px;
+  width: 36px;
+  background-size: cover;
+  background-position: center;
+  flex-shrink: 0;
+}
diff --git a/apps/src/aichat/views/chatWorkspace.module.scss b/apps/src/aichat/views/chatWorkspace.module.scss
index 16c4a41619775..69176e74404c4 100644
--- a/apps/src/aichat/views/chatWorkspace.module.scss
+++ b/apps/src/aichat/views/chatWorkspace.module.scss
@@ -13,12 +13,6 @@ $tabs-default-spacing: 4px;
   overflow: hidden;
 }
 
-.messageEditorRow {
-  display: flex;
-  align-items: center;
-  padding-left: 10px;
-}
-
 %conversation-area,
 .conversationArea {
   flex-grow: 1;
diff --git a/apps/src/aichat/views/mixins.scss b/apps/src/aichat/views/mixins.scss
new file mode 100644
index 0000000000000..c98a725646e5b
--- /dev/null
+++ b/apps/src/aichat/views/mixins.scss
@@ -0,0 +1,12 @@
+@keyframes fade-in {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+
+@mixin fade-in($duration: 0.3s) {
+  animation: fade-in $duration;
+}
diff --git a/apps/src/types/locale.ts b/apps/src/types/locale.ts
index 539ccc4d2bf2e..3710c4efdd6f7 100644
--- a/apps/src/types/locale.ts
+++ b/apps/src/types/locale.ts
@@ -15,7 +15,9 @@
 
 /** Type of the locale object, parameterized by a StringMap type (usually derived from JSON) */
 export type Locale<StringMap> = {
-  [key in keyof StringMap]: (replaceMap?: {[key: string]: string}) => string;
+  [key in keyof StringMap]: (replaceMap?: {
+    [key: string]: string | number;
+  }) => string;
 };
 
 /** Exporting the common locale object as the Locale type, with keys derived from the common strings JSON file */
diff --git a/apps/yarn.lock b/apps/yarn.lock
index c94c661f727d1..4feec76ed09d4 100644
--- a/apps/yarn.lock
+++ b/apps/yarn.lock
@@ -9713,13 +9713,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"attr-accept@npm:^2.2.4":
-  version: 2.2.5
-  resolution: "attr-accept@npm:2.2.5"
-  checksum: 10/474b1c53e62c5b881c745d1f098196f190c8b493245e95d4b0fea9298d3acb56f551868fc12806885277e55e9d8ad3c5963e92d93456f4e4081dfc5190977bfd
-  languageName: node
-  linkType: hard
-
 "automation-events@npm:^7.0.0":
   version: 7.0.0
   resolution: "automation-events@npm:7.0.0"
@@ -10372,7 +10365,6 @@ __metadata:
     react-dom: "npm:^17.0.2"
     react-dom-confetti: "npm:^0.2.0"
     react-draggable: "npm:^4.4.6"
-    react-dropzone: "npm:^14.3.8"
     react-focus-on: "npm:^3.9.4"
     react-google-charts: "npm:2"
     react-idle-timer: "npm:^4.2.7"
@@ -15058,15 +15050,6 @@ canvg@gabelerner/canvg:
   languageName: node
   linkType: hard
 
-"file-selector@npm:^2.1.0":
-  version: 2.1.2
-  resolution: "file-selector@npm:2.1.2"
-  dependencies:
-    tslib: "npm:^2.7.0"
-  checksum: 10/2a6be0e1904df85f8705a5171fd3b93c1b1ff2ad0143556adb78ac4de899bfc0ba1a20083b4febd4f7000759ec9119a31af76a057e29dd9215907da69ac95e50
-  languageName: node
-  linkType: hard
-
 "file-sync-cmp@npm:^0.1.0":
   version: 0.1.1
   resolution: "file-sync-cmp@npm:0.1.1"
@@ -23799,19 +23782,6 @@ canvg@gabelerner/canvg:
   languageName: node
   linkType: hard
 
-"react-dropzone@npm:^14.3.8":
-  version: 14.3.8
-  resolution: "react-dropzone@npm:14.3.8"
-  dependencies:
-    attr-accept: "npm:^2.2.4"
-    file-selector: "npm:^2.1.0"
-    prop-types: "npm:^15.8.1"
-  peerDependencies:
-    react: ">= 16.8 || 18.0.0"
-  checksum: 10/31d5089c2acb4528a279761473a00a1c148b78ae41a6ac49ed09ee94f1d44595437188308ec838debfa0d08082893b170dc072e680aca00469793a05d61b07f2
-  languageName: node
-  linkType: hard
-
 "react-element-to-jsx-string@npm:^15.0.0":
   version: 15.0.0
   resolution: "react-element-to-jsx-string@npm:15.0.0"
@@ -28277,13 +28247,6 @@ canvg@gabelerner/canvg:
   languageName: node
   linkType: hard
 
-"tslib@npm:^2.7.0":
-  version: 2.8.1
-  resolution: "tslib@npm:2.8.1"
-  checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7
-  languageName: node
-  linkType: hard
-
 "tsutils@npm:^3.21.0":
   version: 3.21.0
   resolution: "tsutils@npm:3.21.0"


Reviews:
user: login: breville
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T12:42:38Z
user: login: sanchitmalhotra126
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T19:16:27Z
user: login: bencodeorg
body: A couple of small questions but overall looks great!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T21:44:08Z
user: login: sanchitmalhotra126
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T22:51:47Z
user: login: sanchitmalhotra126
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T22:52:12Z
user: login: sanchitmalhotra126
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T22:53:53Z
user: login: sanchitmalhotra126
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T23:57:28Z
user: login: sanchitmalhotra126
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T18:01:17Z

Review comments:
diff_hunk: @@ -0,0 +1,110 @@
+import {Button} from '@code-dot-org/component-library/button';
+import React, {ChangeEvent, useRef} from 'react';
+
+import Lab2Registry from '@cdo/apps/lab2/Lab2Registry';
+import HttpClient, {NetworkError} from '@cdo/apps/util/HttpClient';
+import {useAppDispatch, useAppSelector} from '@cdo/apps/util/reduxHooks';
+
+import {ACCEPTED_FILE_TYPES, MAX_NUM_FILES} from '../../constants';
+import aichatI18n from '../../locale';
+import {
+  addStagedFile,
+  clearStagedFilesAlert,
+  stagedFilesLimitExceeded,
+  stagedFileUploadFinished,
+} from '../../redux';
+
+const UploadButton: React.FC = () => {
+  const dispatch = useAppDispatch();
+  const inputRef = useRef<HTMLInputElement>(null);
+  const currentChannelId = useAppSelector(state => state.lab.channel?.id);
+  const numStagedFiles = useAppSelector(
+    state => state.aichat.stagedFiles.length
+  );
+
+  const onSelectFile = async (event: ChangeEvent<HTMLInputElement>) => {
+    const files = event.target.files;
+    if (!files) {
+      return;
+    }
+
+    // Clear the alert, if any
+    dispatch(clearStagedFilesAlert());
+
+    const numAllowedFiles = MAX_NUM_FILES - numStagedFiles;
+
+    if (files.length > numAllowedFiles) {
+      dispatch(stagedFilesLimitExceeded());
+    }
+
+    const allowedFiles = Array.from(files)
+      .slice(0, numAllowedFiles)
+      .map<[string, string, File]>(file => [
+        // Create a unique key for each upload in case the same file is uploaded more than once.
+        `${file.name}-${Date.now()}`,
+        file.name,
+        file,
+      ]);
+
+    console.log(allowedFiles);
path: apps/src/aichat/views/assets/UploadButton.tsx
user: login: breville
body: Intentional logging?
created_at: 2025-03-12T12:42:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,110 @@
+import {Button} from '@code-dot-org/component-library/button';
+import React, {ChangeEvent, useRef} from 'react';
+
+import Lab2Registry from '@cdo/apps/lab2/Lab2Registry';
+import HttpClient, {NetworkError} from '@cdo/apps/util/HttpClient';
+import {useAppDispatch, useAppSelector} from '@cdo/apps/util/reduxHooks';
+
+import {ACCEPTED_FILE_TYPES, MAX_NUM_FILES} from '../../constants';
+import aichatI18n from '../../locale';
+import {
+  addStagedFile,
+  clearStagedFilesAlert,
+  stagedFilesLimitExceeded,
+  stagedFileUploadFinished,
+} from '../../redux';
+
+const UploadButton: React.FC = () => {
+  const dispatch = useAppDispatch();
+  const inputRef = useRef<HTMLInputElement>(null);
+  const currentChannelId = useAppSelector(state => state.lab.channel?.id);
+  const numStagedFiles = useAppSelector(
+    state => state.aichat.stagedFiles.length
+  );
+
+  const onSelectFile = async (event: ChangeEvent<HTMLInputElement>) => {
+    const files = event.target.files;
+    if (!files) {
+      return;
+    }
+
+    // Clear the alert, if any
+    dispatch(clearStagedFilesAlert());
+
+    const numAllowedFiles = MAX_NUM_FILES - numStagedFiles;
+
+    if (files.length > numAllowedFiles) {
+      dispatch(stagedFilesLimitExceeded());
+    }
+
+    const allowedFiles = Array.from(files)
+      .slice(0, numAllowedFiles)
+      .map<[string, string, File]>(file => [
+        // Create a unique key for each upload in case the same file is uploaded more than once.
+        `${file.name}-${Date.now()}`,
+        file.name,
+        file,
+      ]);
+
+    console.log(allowedFiles);
path: apps/src/aichat/views/assets/UploadButton.tsx
user: login: sanchitmalhotra126
body: Good catch! Removed
created_at: 2025-03-12T19:16:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,74 +65,4 @@ const UserChatMessageEditor: React.FunctionComponent<{
   );
 };
 
-const plusIcon: FontAwesomeV6IconProps = {
-  iconName: 'plus',
-};
-const spinIcon: FontAwesomeV6IconProps = {
-  iconName: 'spinner',
-  animationType: 'spin',
-};
-
-/** Upload button for adding assets to current chat message. UI is not final (hidden behind URL param) */
-interface UploadProps {
-  inputRef: RefObject<HTMLTextAreaElement>;
-  children: React.ReactNode;
-}
-const UploadButton: React.FC<UploadProps> = ({inputRef, children}) => {
-  const dispatch = useAppDispatch();
-  const currentChannelId = useAppSelector(state => state.lab.channel?.id);
-  const [loading, setLoading] = useState(false);
-  const onDrop = useCallback(
-    async (acceptedFiles: File[]) => {
-      if (currentChannelId) {
-        setLoading(true);
-        const assets: string[] = [];
-        // Cap at 3 for now
-        for (const file of acceptedFiles.slice(0, 3)) {
-          const response = await HttpClient.put(
-            `/v3/assets/${currentChannelId}/${file.name}`,
-            file
-          );
-          const {filename} = (await response.json()) as UploadAssetResponse;
-          assets.push(filename);
-        }
-
-        // Temp solution: grab the input value from the ref and submit when choosing assets.
-        dispatch(
-          submitChatContents({
-            text: inputRef.current?.value || '',
-            assets,
-          })
-        );
-        setLoading(false);
-      }
-    },
-    [currentChannelId, dispatch, inputRef]
-  );
-  const {getRootProps, getInputProps, isDragActive} = useDropzone({
path: apps/src/aichat/views/UserChatMessageEditor.tsx
user: login: bencodeorg
body: Are we going to bring back dropzone, or should we remove that dependency?
created_at: 2025-03-13T19:30:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,134 @@
+import Alert from '@code-dot-org/component-library/alert';
+import FontAwesomeV6Icon from '@code-dot-org/component-library/fontAwesomeV6Icon';
+import {WithTooltip} from '@code-dot-org/component-library/tooltip';
+import {StrongText} from '@code-dot-org/component-library/typography';
+import classNames from 'classnames';
+import React from 'react';
+
+import {useAppDispatch, useAppSelector} from '@cdo/apps/util/reduxHooks';
+
+import {MAX_FILE_SIZE_MB, MAX_NUM_FILES} from '../../constants';
+import aichatI18n from '../../locale';
+import {clearStagedFilesAlert, removeStagedFile} from '../../redux';
+
+import styles from './staged-files-preview.module.scss';
+
+const alerts = {
+  uploadFailed: [aichatI18n.uploadFailedAlert(), 'danger'] as const,
+  fileLimitExceeded: [
+    aichatI18n.uploadFileLimitExceededAlert({maximum: MAX_NUM_FILES}),
+    'warning',
+  ] as const,
+  sizeLimitExceeded: [
+    aichatI18n.uploadSizeLimitExceededAlert({maximum: MAX_FILE_SIZE_MB}),
+    'danger',
+  ] as const,
+};
+
+const StagedFilesPreview: React.FC = () => {
+  const dispatch = useAppDispatch();
+  const stagedFiles = useAppSelector(state => state.aichat.stagedFiles);
+  const currentChannelId = useAppSelector(state => state.lab.channel?.id);
+
+  const [alertMessage, style] = useAppSelector(state => {
+    if (!state.aichat.stagedFilesAlert) {
+      return [];
+    }
+
+    return alerts[state.aichat.stagedFilesAlert];
+  });
+
+  if (stagedFiles.length === 0 && !alertMessage) {
+    return null;
+  }
+
+  return (
+    <div className={styles.container}>
+      <div className={styles.row}>
+        {stagedFiles.map(({key, filename, status}) => {
+          return (
+            <FilePreview
+              key={key}
+              type={filename.endsWith('.pdf') ? 'pdf' : 'image'}
+              url={`/v3/assets/${currentChannelId}/${encodeURIComponent(
path: apps/src/aichat/views/assets/StagedFilesPreview.tsx
user: login: bencodeorg
body: Noticing a problem with file names that have parentheses in them not showing up in the staging area, but they do render once in the chat window. Something around encoding?
created_at: 2025-03-13T19:43:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,116 @@
+import {Button} from '@code-dot-org/component-library/button';
+import React, {ChangeEvent, useCallback, useRef} from 'react';
+
+import Lab2Registry from '@cdo/apps/lab2/Lab2Registry';
+import HttpClient, {NetworkError} from '@cdo/apps/util/HttpClient';
+import {useAppDispatch, useAppSelector} from '@cdo/apps/util/reduxHooks';
+
+import {ACCEPTED_FILE_TYPES, MAX_NUM_FILES} from '../../constants';
+import aichatI18n from '../../locale';
+import {
+  addStagedFile,
+  clearStagedFilesAlert,
+  stagedFilesLimitExceeded,
+  stagedFileUploadFinished,
+} from '../../redux';
+
+const UploadButton: React.FC = () => {
+  const dispatch = useAppDispatch();
+  const inputRef = useRef<HTMLInputElement>(null);
+  const currentChannelId = useAppSelector(state => state.lab.channel?.id);
+  const numStagedFiles = useAppSelector(
+    state => state.aichat.stagedFiles.length
+  );
+
+  const onSelectFile = async (event: ChangeEvent<HTMLInputElement>) => {
+    const files = event.target.files;
+    if (!files) {
+      return;
+    }
+
+    // Clear the alert, if any
+    dispatch(clearStagedFilesAlert());
+
+    const numAllowedFiles = MAX_NUM_FILES - numStagedFiles;
+
+    if (files.length > numAllowedFiles) {
+      dispatch(stagedFilesLimitExceeded());
path: apps/src/aichat/views/assets/UploadButton.tsx
user: login: bencodeorg
body: This is a nice detail :)
created_at: 2025-03-13T19:51:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,116 @@
+import {Button} from '@code-dot-org/component-library/button';
+import React, {ChangeEvent, useCallback, useRef} from 'react';
+
+import Lab2Registry from '@cdo/apps/lab2/Lab2Registry';
+import HttpClient, {NetworkError} from '@cdo/apps/util/HttpClient';
+import {useAppDispatch, useAppSelector} from '@cdo/apps/util/reduxHooks';
+
+import {ACCEPTED_FILE_TYPES, MAX_NUM_FILES} from '../../constants';
+import aichatI18n from '../../locale';
+import {
+  addStagedFile,
+  clearStagedFilesAlert,
+  stagedFilesLimitExceeded,
+  stagedFileUploadFinished,
+} from '../../redux';
+
+const UploadButton: React.FC = () => {
+  const dispatch = useAppDispatch();
+  const inputRef = useRef<HTMLInputElement>(null);
+  const currentChannelId = useAppSelector(state => state.lab.channel?.id);
+  const numStagedFiles = useAppSelector(
+    state => state.aichat.stagedFiles.length
+  );
+
+  const onSelectFile = async (event: ChangeEvent<HTMLInputElement>) => {
+    const files = event.target.files;
+    if (!files) {
+      return;
+    }
+
+    // Clear the alert, if any
+    dispatch(clearStagedFilesAlert());
+
+    const numAllowedFiles = MAX_NUM_FILES - numStagedFiles;
+
+    if (files.length > numAllowedFiles) {
+      dispatch(stagedFilesLimitExceeded());
+    }
+
+    const allowedFiles = Array.from(files)
+      .slice(0, numAllowedFiles)
+      .map<[string, string, File]>(file => [
+        // Create a unique key for each upload in case the same file is uploaded more than once.
path: apps/src/aichat/views/assets/UploadButton.tsx
user: login: bencodeorg
body: How is it possible to select the same file twice? Isn't this just for a single upload action?
created_at: 2025-03-13T20:00:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,74 +65,4 @@ const UserChatMessageEditor: React.FunctionComponent<{
   );
 };
 
-const plusIcon: FontAwesomeV6IconProps = {
-  iconName: 'plus',
-};
-const spinIcon: FontAwesomeV6IconProps = {
-  iconName: 'spinner',
-  animationType: 'spin',
-};
-
-/** Upload button for adding assets to current chat message. UI is not final (hidden behind URL param) */
-interface UploadProps {
-  inputRef: RefObject<HTMLTextAreaElement>;
-  children: React.ReactNode;
-}
-const UploadButton: React.FC<UploadProps> = ({inputRef, children}) => {
-  const dispatch = useAppDispatch();
-  const currentChannelId = useAppSelector(state => state.lab.channel?.id);
-  const [loading, setLoading] = useState(false);
-  const onDrop = useCallback(
-    async (acceptedFiles: File[]) => {
-      if (currentChannelId) {
-        setLoading(true);
-        const assets: string[] = [];
-        // Cap at 3 for now
-        for (const file of acceptedFiles.slice(0, 3)) {
-          const response = await HttpClient.put(
-            `/v3/assets/${currentChannelId}/${file.name}`,
-            file
-          );
-          const {filename} = (await response.json()) as UploadAssetResponse;
-          assets.push(filename);
-        }
-
-        // Temp solution: grab the input value from the ref and submit when choosing assets.
-        dispatch(
-          submitChatContents({
-            text: inputRef.current?.value || '',
-            assets,
-          })
-        );
-        setLoading(false);
-      }
-    },
-    [currentChannelId, dispatch, inputRef]
-  );
-  const {getRootProps, getInputProps, isDragActive} = useDropzone({
path: apps/src/aichat/views/UserChatMessageEditor.tsx
user: login: sanchitmalhotra126
body: Good question; wasn't sure but I can check with Mark if we want this behavior. In the interim, i'll go ahead and remove so we're not bloating our node modules.
created_at: 2025-03-13T22:51:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,134 @@
+import Alert from '@code-dot-org/component-library/alert';
+import FontAwesomeV6Icon from '@code-dot-org/component-library/fontAwesomeV6Icon';
+import {WithTooltip} from '@code-dot-org/component-library/tooltip';
+import {StrongText} from '@code-dot-org/component-library/typography';
+import classNames from 'classnames';
+import React from 'react';
+
+import {useAppDispatch, useAppSelector} from '@cdo/apps/util/reduxHooks';
+
+import {MAX_FILE_SIZE_MB, MAX_NUM_FILES} from '../../constants';
+import aichatI18n from '../../locale';
+import {clearStagedFilesAlert, removeStagedFile} from '../../redux';
+
+import styles from './staged-files-preview.module.scss';
+
+const alerts = {
+  uploadFailed: [aichatI18n.uploadFailedAlert(), 'danger'] as const,
+  fileLimitExceeded: [
+    aichatI18n.uploadFileLimitExceededAlert({maximum: MAX_NUM_FILES}),
+    'warning',
+  ] as const,
+  sizeLimitExceeded: [
+    aichatI18n.uploadSizeLimitExceededAlert({maximum: MAX_FILE_SIZE_MB}),
+    'danger',
+  ] as const,
+};
+
+const StagedFilesPreview: React.FC = () => {
+  const dispatch = useAppDispatch();
+  const stagedFiles = useAppSelector(state => state.aichat.stagedFiles);
+  const currentChannelId = useAppSelector(state => state.lab.channel?.id);
+
+  const [alertMessage, style] = useAppSelector(state => {
+    if (!state.aichat.stagedFilesAlert) {
+      return [];
+    }
+
+    return alerts[state.aichat.stagedFilesAlert];
+  });
+
+  if (stagedFiles.length === 0 && !alertMessage) {
+    return null;
+  }
+
+  return (
+    <div className={styles.container}>
+      <div className={styles.row}>
+        {stagedFiles.map(({key, filename, status}) => {
+          return (
+            <FilePreview
+              key={key}
+              type={filename.endsWith('.pdf') ? 'pdf' : 'image'}
+              url={`/v3/assets/${currentChannelId}/${encodeURIComponent(
path: apps/src/aichat/views/assets/StagedFilesPreview.tsx
user: login: sanchitmalhotra126
body: Oh interesting - I added `encodeURIComponent` to handle files with spaces in the name, but didn't test parentheses. Will take a look!
created_at: 2025-03-13T22:52:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,116 @@
+import {Button} from '@code-dot-org/component-library/button';
+import React, {ChangeEvent, useCallback, useRef} from 'react';
+
+import Lab2Registry from '@cdo/apps/lab2/Lab2Registry';
+import HttpClient, {NetworkError} from '@cdo/apps/util/HttpClient';
+import {useAppDispatch, useAppSelector} from '@cdo/apps/util/reduxHooks';
+
+import {ACCEPTED_FILE_TYPES, MAX_NUM_FILES} from '../../constants';
+import aichatI18n from '../../locale';
+import {
+  addStagedFile,
+  clearStagedFilesAlert,
+  stagedFilesLimitExceeded,
+  stagedFileUploadFinished,
+} from '../../redux';
+
+const UploadButton: React.FC = () => {
+  const dispatch = useAppDispatch();
+  const inputRef = useRef<HTMLInputElement>(null);
+  const currentChannelId = useAppSelector(state => state.lab.channel?.id);
+  const numStagedFiles = useAppSelector(
+    state => state.aichat.stagedFiles.length
+  );
+
+  const onSelectFile = async (event: ChangeEvent<HTMLInputElement>) => {
+    const files = event.target.files;
+    if (!files) {
+      return;
+    }
+
+    // Clear the alert, if any
+    dispatch(clearStagedFilesAlert());
+
+    const numAllowedFiles = MAX_NUM_FILES - numStagedFiles;
+
+    if (files.length > numAllowedFiles) {
+      dispatch(stagedFilesLimitExceeded());
+    }
+
+    const allowedFiles = Array.from(files)
+      .slice(0, numAllowedFiles)
+      .map<[string, string, File]>(file => [
+        // Create a unique key for each upload in case the same file is uploaded more than once.
path: apps/src/aichat/views/assets/UploadButton.tsx
user: login: sanchitmalhotra126
body: You can upload the same file in successive attempts (i.e. select the same file time you click the button) - this makes sure that the key with which we store the file in redux is unique.
created_at: 2025-03-13T22:53:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,134 @@
+import Alert from '@code-dot-org/component-library/alert';
+import FontAwesomeV6Icon from '@code-dot-org/component-library/fontAwesomeV6Icon';
+import {WithTooltip} from '@code-dot-org/component-library/tooltip';
+import {StrongText} from '@code-dot-org/component-library/typography';
+import classNames from 'classnames';
+import React from 'react';
+
+import {useAppDispatch, useAppSelector} from '@cdo/apps/util/reduxHooks';
+
+import {MAX_FILE_SIZE_MB, MAX_NUM_FILES} from '../../constants';
+import aichatI18n from '../../locale';
+import {clearStagedFilesAlert, removeStagedFile} from '../../redux';
+
+import styles from './staged-files-preview.module.scss';
+
+const alerts = {
+  uploadFailed: [aichatI18n.uploadFailedAlert(), 'danger'] as const,
+  fileLimitExceeded: [
+    aichatI18n.uploadFileLimitExceededAlert({maximum: MAX_NUM_FILES}),
+    'warning',
+  ] as const,
+  sizeLimitExceeded: [
+    aichatI18n.uploadSizeLimitExceededAlert({maximum: MAX_FILE_SIZE_MB}),
+    'danger',
+  ] as const,
+};
+
+const StagedFilesPreview: React.FC = () => {
+  const dispatch = useAppDispatch();
+  const stagedFiles = useAppSelector(state => state.aichat.stagedFiles);
+  const currentChannelId = useAppSelector(state => state.lab.channel?.id);
+
+  const [alertMessage, style] = useAppSelector(state => {
+    if (!state.aichat.stagedFilesAlert) {
+      return [];
+    }
+
+    return alerts[state.aichat.stagedFilesAlert];
+  });
+
+  if (stagedFiles.length === 0 && !alertMessage) {
+    return null;
+  }
+
+  return (
+    <div className={styles.container}>
+      <div className={styles.row}>
+        {stagedFiles.map(({key, filename, status}) => {
+          return (
+            <FilePreview
+              key={key}
+              type={filename.endsWith('.pdf') ? 'pdf' : 'image'}
+              url={`/v3/assets/${currentChannelId}/${encodeURIComponent(
path: apps/src/aichat/views/assets/StagedFilesPreview.tsx
user: login: sanchitmalhotra126
body: Good catch! Looks like we need single quotes around the background image URL (otherwise it treats parentheses as closing the `url()` tag I think and just removes itself)
created_at: 2025-03-13T23:57:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,74 +65,4 @@ const UserChatMessageEditor: React.FunctionComponent<{
   );
 };
 
-const plusIcon: FontAwesomeV6IconProps = {
-  iconName: 'plus',
-};
-const spinIcon: FontAwesomeV6IconProps = {
-  iconName: 'spinner',
-  animationType: 'spin',
-};
-
-/** Upload button for adding assets to current chat message. UI is not final (hidden behind URL param) */
-interface UploadProps {
-  inputRef: RefObject<HTMLTextAreaElement>;
-  children: React.ReactNode;
-}
-const UploadButton: React.FC<UploadProps> = ({inputRef, children}) => {
-  const dispatch = useAppDispatch();
-  const currentChannelId = useAppSelector(state => state.lab.channel?.id);
-  const [loading, setLoading] = useState(false);
-  const onDrop = useCallback(
-    async (acceptedFiles: File[]) => {
-      if (currentChannelId) {
-        setLoading(true);
-        const assets: string[] = [];
-        // Cap at 3 for now
-        for (const file of acceptedFiles.slice(0, 3)) {
-          const response = await HttpClient.put(
-            `/v3/assets/${currentChannelId}/${file.name}`,
-            file
-          );
-          const {filename} = (await response.json()) as UploadAssetResponse;
-          assets.push(filename);
-        }
-
-        // Temp solution: grab the input value from the ref and submit when choosing assets.
-        dispatch(
-          submitChatContents({
-            text: inputRef.current?.value || '',
-            assets,
-          })
-        );
-        setLoading(false);
-      }
-    },
-    [currentChannelId, dispatch, inputRef]
-  );
-  const {getRootProps, getInputProps, isDragActive} = useDropzone({
path: apps/src/aichat/views/UserChatMessageEditor.tsx
user: login: sanchitmalhotra126
body: Removed for now
created_at: 2025-03-14T18:01:17Z
author_association: CONTRIBUTOR

PR comments:
user: login: dju90
created_at: 2025-03-12T03:54:43Z
body: Nit: instead of the x appearing on hover, I would recommend always showing it. I think it's generally better to always show the affordance for removing a file, and it would be more consistent with chatgpt's UI
author_association: CONTRIBUTOR
user: login: dju90
created_at: 2025-03-12T03:57:40Z
body: Another thing I'd be curious for Mark's opinion about: I think I'd recommend staging the files above the chat box instead of below. Imo it seems more natural to ask a question (aka enter a chat) about files above rather than files below. This would also be more consistent with chatgpt's UI.
author_association: CONTRIBUTOR
user: login: breville
created_at: 2025-03-12T12:47:02Z
body: This is looking really good.

> instead of the x appearing on hover, I would recommend always showing it. I think it's generally better to always show the affordance for removing a file

+1, especially since touch devices don't support hover.

> I think I'd recommend staging the files above the chat box instead of below. Imo it seems more natural to ask a question (aka enter a chat) about files above rather than files below.

+1.  After using it, it did seem it would be more natural to have the images appear above the text, which also keeps the text entry box where it is.  Presumably the images would also show above the text in the conversation view after being submitted.

One other issue I did observe: if you upload a file, then remove it, then try to upload it again, it seems to do nothing.
author_association: MEMBER
user: login: sanchitmalhotra126
created_at: 2025-03-12T19:17:52Z
body: > Nit: instead of the x appearing on hover, I would recommend always showing it. I think it's generally better to always show the affordance for removing a file, and it would be more consistent with chatgpt's UI

> Another thing I'd be curious for Mark's opinion about: I think I'd recommend staging the files above the chat box instead of below. Imo it seems more natural to ask a question (aka enter a chat) about files above rather than files below. This would also be more consistent with chatgpt's UI.

Done!

<img width="587" alt="Screenshot 2025-03-12 at 12 16 03 PM" src="https://github.com/user-attachments/assets/48c643e6-daeb-4c6b-8955-f7f70f398d1e" />
author_association: CONTRIBUTOR
user: login: sanchitmalhotra126
created_at: 2025-03-12T19:18:57Z
body: > One other issue I did observe: if you upload a file, then remove it, then try to upload it again, it seems to do nothing.

Good catch! Fixed this (apparently we need to clear the HTML input value in between clicks)
author_association: CONTRIBUTOR