Compare commits

...

3 Commits

Author SHA1 Message Date
rohoswagger
6cb9632851 remove console.log 2026-02-04 13:33:58 -08:00
rohoswagger
26fd637b74 feat(craft): file upload 2026-02-04 13:33:58 -08:00
rohoswagger
0a16675299 fix(craft): file upload 2026-02-04 13:33:58 -08:00
7 changed files with 754 additions and 103 deletions

View File

@@ -1941,7 +1941,11 @@ echo '{tar_b64}' | base64 -d | tar -xzf -
) -> str:
"""Upload a file to the session's attachments directory.
Uses tar streaming via stdin for efficient binary transfer.
Uses tar streaming via stdin with explicit byte count to avoid EOF issues.
The K8s Python client cannot close stdin without closing the entire WebSocket
connection, so we use `head -c <size>` to read exactly the expected bytes
instead of waiting for EOF.
Handles filename collisions atomically within the shell script.
Args:
@@ -1966,13 +1970,15 @@ echo '{tar_b64}' | base64 -d | tar -xzf -
tarinfo.size = len(content)
tar.addfile(tarinfo, io.BytesIO(content))
tar_data = tar_buffer.getvalue()
tar_size = len(tar_data)
# Shell script that:
# 1. Creates target directory and temp extraction directory
# 2. Extracts tar to temp directory
# 3. Moves file to target with collision handling
# 4. Cleans up temp directory
# 5. Outputs final filename
# 2. Reads exactly tar_size bytes from stdin (avoids needing EOF signal)
# 3. Extracts tar to temp directory
# 4. Moves file to target with collision handling
# 5. Cleans up temp directory
# 6. Outputs final filename
script = f"""
set -e
target_dir="{target_dir}"
@@ -1980,7 +1986,9 @@ tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
mkdir -p "$target_dir"
tar xf - -C "$tmpdir"
# Read exactly {tar_size} bytes and extract (avoids waiting for EOF)
head -c {tar_size} | tar xf - -C "$tmpdir"
# Find the extracted file (first file in tmpdir)
original=$(ls -1 "$tmpdir" | head -1)
@@ -2018,9 +2026,9 @@ echo "$base"
# Write tar data to stdin
ws_client.write_stdin(tar_data)
ws_client.close()
# Read response
# Read response - head -c will read exactly tar_size bytes and proceed,
# so we don't need to close stdin to signal EOF
stdout_data = ""
stderr_data = ""
while ws_client.is_open():
@@ -2047,7 +2055,7 @@ echo "$base"
logger.info(
f"Uploaded file to session {session_id}: attachments/{final_filename} "
f"({len(content)} bytes via tar)"
f"({len(content)} bytes)"
)
return f"attachments/{final_filename}"

View File

@@ -19,8 +19,6 @@ interface BuildWelcomeProps {
isRunning: boolean;
/** When true, shows spinner on send button with "Initializing sandbox..." tooltip */
sandboxInitializing?: boolean;
/** Pre-provisioned session ID for file uploads before a session is active. */
preProvisionedSessionId?: string | null;
}
/**
@@ -32,7 +30,6 @@ export default function BuildWelcome({
onSubmit,
isRunning,
sandboxInitializing = false,
preProvisionedSessionId,
}: BuildWelcomeProps) {
const inputBarRef = useRef<InputBarHandle>(null);
const userPersona = getBuildUserPersona();
@@ -57,7 +54,7 @@ export default function BuildWelcome({
isRunning={isRunning}
placeholder="Analyze my data and create a dashboard..."
sandboxInitializing={sandboxInitializing}
preProvisionedSessionId={preProvisionedSessionId}
isWelcomePage
/>
<ConnectDataBanner />
<SuggestedPrompts persona={persona} onPromptClick={handlePromptClick} />

View File

@@ -25,7 +25,6 @@ import {
UploadFileStatus,
useUploadFilesContext,
} from "@/app/craft/contexts/UploadFilesContext";
import { uploadFile } from "@/app/craft/services/apiServices";
import { CRAFT_SEARCH_PARAM_NAMES } from "@/app/craft/services/searchParams";
import { CRAFT_PATH } from "@/app/craft/v1/constants";
import { usePopup } from "@/components/admin/connectors/Popup";
@@ -120,7 +119,8 @@ export default function BuildChatPanel({
// Disable input when pre-provisioning is in progress or failed (waiting for retry)
const sandboxNotReady = isPreProvisioning || isPreProvisioningFailed;
const { currentMessageFiles, hasUploadingFiles } = useUploadFilesContext();
const { currentMessageFiles, hasUploadingFiles, setActiveSession } =
useUploadFilesContext();
const followupSuggestions = useFollowupSuggestions();
const suggestionsLoading = useSuggestionsLoading();
const clearFollowupSuggestions = useBuildSessionStore(
@@ -133,6 +133,16 @@ export default function BuildChatPanel({
currentFilesRef.current = currentMessageFiles;
}, [currentMessageFiles]);
/**
* Keep the upload context in sync with the active session.
* The context handles all session change logic internally (fetching attachments,
* clearing files, auto-uploading pending files).
*/
useEffect(() => {
const activeSession = existingSessionId ?? preProvisionedSessionId ?? null;
setActiveSession(activeSession);
}, [existingSessionId, preProvisionedSessionId, setActiveSession]);
// Ref to access InputBar methods
const inputBarRef = useRef<InputBarHandle>(null);
@@ -332,23 +342,7 @@ export default function BuildChatPanel({
});
}
// Upload any files that need to be uploaded:
// - PENDING: Was attached before session existed, needs upload now
// - FAILED: Previous upload failed, retry
// - No path + not currently uploading: Edge case fallback
const currentFiles = currentFilesRef.current;
const filesToUpload = currentFiles.filter(
(f) =>
f.file &&
(f.status === UploadFileStatus.PENDING ||
f.status === UploadFileStatus.FAILED ||
(!f.path && f.status !== UploadFileStatus.UPLOADING))
);
if (filesToUpload.length > 0) {
await Promise.all(
filesToUpload.map((f) => uploadFile(newSessionId, f.file!))
);
}
// Note: PENDING files are auto-uploaded by the context when session becomes available
// Navigate to URL - session controller will set currentSessionId
router.push(
@@ -438,7 +432,6 @@ export default function BuildChatPanel({
onSubmit={handleSubmit}
isRunning={isRunning}
sandboxInitializing={sandboxNotReady}
preProvisionedSessionId={preProvisionedSessionId}
/>
) : (
<BuildMessageList
@@ -499,8 +492,6 @@ export default function BuildChatPanel({
onSubmit={handleSubmit}
isRunning={isRunning}
placeholder="Continue the conversation..."
sessionId={sessionId ?? undefined}
preProvisionedSessionId={preProvisionedSessionId}
/>
</div>
</div>

View File

@@ -26,6 +26,7 @@ import SelectButton from "@/refresh-components/buttons/SelectButton";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import {
SvgArrowUp,
SvgClock,
SvgFileText,
SvgImage,
SvgLoader,
@@ -52,14 +53,12 @@ export interface InputBarProps {
isRunning: boolean;
disabled?: boolean;
placeholder?: string;
/** Session ID for immediate file uploads. If provided, files upload immediately when attached. */
sessionId?: string;
/** Pre-provisioned session ID for file uploads before a session is active. */
preProvisionedSessionId?: string | null;
/** When true, shows spinner on send button with "Initializing sandbox..." tooltip */
sandboxInitializing?: boolean;
/** When true, removes bottom rounding to allow seamless connection with components below */
noBottomRounding?: boolean;
/** Whether this is the welcome page (no existing session in URL). Used for Demo Data pill. */
isWelcomePage?: boolean;
}
/**
@@ -74,6 +73,7 @@ function BuildFileCard({
}) {
const isImage = isImageFile(file.name);
const isUploading = file.status === UploadFileStatus.UPLOADING;
const isPending = file.status === UploadFileStatus.PENDING;
const isFailed = file.status === UploadFileStatus.FAILED;
const cardContent = (
@@ -87,6 +87,8 @@ function BuildFileCard({
>
{isUploading ? (
<SvgLoader className="h-4 w-4 animate-spin text-text-03" />
) : isPending ? (
<SvgClock className="h-4 w-4 text-text-03" />
) : isFailed ? (
<SvgAlertCircle className="h-4 w-4 text-status-error-02" />
) : isImage ? (
@@ -111,7 +113,7 @@ function BuildFileCard({
</div>
);
// Wrap in tooltip if there's an error
// Wrap in tooltip for error or pending status
if (isFailed && file.error) {
return (
<SimpleTooltip tooltip={file.error} side="top">
@@ -120,9 +122,30 @@ function BuildFileCard({
);
}
if (isPending) {
return (
<SimpleTooltip tooltip="Waiting for session to be ready..." side="top">
{cardContent}
</SimpleTooltip>
);
}
return cardContent;
}
/**
* InputBar - Text input with file attachment support
*
* File upload state is managed by UploadFilesContext. This component just:
* - Triggers file selection/paste
* - Displays attached files
* - Handles message submission
*
* The context handles:
* - Session binding (which session to upload to)
* - Auto-upload when session becomes available
* - Fetching existing attachments on session change
*/
const InputBar = memo(
forwardRef<InputBarHandle, InputBarProps>(
(
@@ -131,10 +154,9 @@ const InputBar = memo(
isRunning,
disabled = false,
placeholder = "Describe your task...",
sessionId,
preProvisionedSessionId,
sandboxInitializing = false,
noBottomRounding = false,
isWelcomePage = false,
},
ref
) => {
@@ -142,9 +164,6 @@ const InputBar = memo(
const demoDataEnabled = useDemoDataEnabled();
const [message, setMessage] = useState("");
// Use active session ID, falling back to pre-provisioned session ID
const effectiveSessionId =
sessionId ?? preProvisionedSessionId ?? undefined;
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -200,11 +219,11 @@ const InputBar = memo(
async (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
// Pass effectiveSessionId so files upload immediately if session exists
uploadFiles(Array.from(files), effectiveSessionId);
// Context handles session binding internally
uploadFiles(Array.from(files));
e.target.value = "";
},
[uploadFiles, effectiveSessionId]
[uploadFiles]
);
const handlePaste = useCallback(
@@ -221,12 +240,12 @@ const InputBar = memo(
}
if (pastedFiles.length > 0) {
event.preventDefault();
// Pass effectiveSessionId so files upload immediately if session exists
uploadFiles(pastedFiles, effectiveSessionId);
// Context handles session binding internally
uploadFiles(pastedFiles);
}
}
},
[uploadFiles, effectiveSessionId]
[uploadFiles]
);
const handleInputChange = useCallback(
@@ -308,7 +327,7 @@ const InputBar = memo(
<BuildFileCard
key={file.id}
file={file}
onRemove={(id) => removeFile(id, effectiveSessionId)}
onRemove={removeFile}
/>
))}
</div>
@@ -356,8 +375,8 @@ const InputBar = memo(
disabled={disabled}
onClick={() => fileInputRef.current?.click()}
/>
{/* Demo Data indicator pill - only show on welcome page (no session) when demo data is enabled */}
{demoDataEnabled && !sessionId && (
{/* Demo Data indicator pill - only show on welcome page when demo data is enabled */}
{demoDataEnabled && isWelcomePage && (
<SimpleTooltip
tooltip="Switch to your data in the Configure panel!"
side="top"

View File

@@ -6,13 +6,14 @@ import {
useState,
useCallback,
useMemo,
useRef,
useEffect,
type ReactNode,
type Dispatch,
type SetStateAction,
} from "react";
import {
uploadFile as uploadFileApi,
deleteFile as deleteFileApi,
fetchDirectoryListing,
} from "@/app/craft/services/apiServices";
/**
@@ -58,6 +59,148 @@ const generateTempId = () => {
}
};
// =============================================================================
// File Validation (matches backend: build/configs.py and build/utils.py)
// =============================================================================
/** Maximum individual file size - matches BUILD_MAX_UPLOAD_FILE_SIZE_MB (50MB) */
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
/** Maximum total attachment size per session - matches BUILD_MAX_TOTAL_UPLOAD_SIZE_MB (200MB) */
const MAX_TOTAL_SIZE_BYTES = 200 * 1024 * 1024;
/** Maximum files per session - matches BUILD_MAX_UPLOAD_FILES_PER_SESSION */
const MAX_FILES_PER_SESSION = 20;
/** Blocked file extensions (executables/dangerous) - matches backend BLOCKED_EXTENSIONS */
const BLOCKED_EXTENSIONS = new Set([
// Windows executables
".exe",
".dll",
".msi",
".scr",
".com",
".bat",
".cmd",
".ps1",
// macOS
".app",
".dmg",
".pkg",
// Linux
".deb",
".rpm",
".so",
// Cross-platform
".jar",
".war",
".ear",
// Other potentially dangerous
".vbs",
".vbe",
".wsf",
".wsh",
".hta",
".cpl",
".reg",
".lnk",
".pif",
]);
/** Format bytes to human-readable string */
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/** Get file extension (lowercase, including dot) */
function getFileExtension(filename: string): string {
const lastDot = filename.lastIndexOf(".");
if (lastDot === -1) return "";
return filename.slice(lastDot).toLowerCase();
}
/** Validation result for a single file */
interface FileValidationResult {
valid: boolean;
error?: string;
}
/** Validate a single file before upload */
function validateFile(file: File): FileValidationResult {
// Check file size
if (file.size > MAX_FILE_SIZE_BYTES) {
return {
valid: false,
error: `File too large (${formatBytes(
file.size
)}). Maximum is ${formatBytes(MAX_FILE_SIZE_BYTES)}.`,
};
}
// Check blocked extensions
const ext = getFileExtension(file.name);
if (ext && BLOCKED_EXTENSIONS.has(ext)) {
return {
valid: false,
error: `File type '${ext}' is not allowed for security reasons.`,
};
}
// Check for missing extension
if (!ext) {
return {
valid: false,
error: "File must have an extension.",
};
}
return { valid: true };
}
/** Validate total files and size constraints */
function validateBatch(
newFiles: File[],
existingFiles: BuildFile[]
): FileValidationResult {
const totalCount = existingFiles.length + newFiles.length;
if (totalCount > MAX_FILES_PER_SESSION) {
return {
valid: false,
error: `Too many files. Maximum is ${MAX_FILES_PER_SESSION} files per session.`,
};
}
const existingSize = existingFiles.reduce((sum, f) => sum + f.size, 0);
const newSize = newFiles.reduce((sum, f) => sum + f.size, 0);
const totalSize = existingSize + newSize;
if (totalSize > MAX_TOTAL_SIZE_BYTES) {
return {
valid: false,
error: `Total size exceeds limit. Maximum is ${formatBytes(
MAX_TOTAL_SIZE_BYTES
)} per session.`,
};
}
return { valid: true };
}
/** Create a failed BuildFile for validation errors */
function createFailedFile(file: File, error: string): BuildFile {
return {
id: generateTempId(),
name: file.name,
status: UploadFileStatus.FAILED,
file_type: file.type,
size: file.size,
created_at: new Date().toISOString(),
error,
};
}
// Create optimistic file from File object
const createOptimisticFile = (file: File): BuildFile => {
const tempId = generateTempId();
@@ -72,22 +215,99 @@ const createOptimisticFile = (file: File): BuildFile => {
};
};
/**
* Error types for better error handling
*/
export enum UploadErrorType {
NETWORK = "NETWORK",
AUTH = "AUTH",
NOT_FOUND = "NOT_FOUND",
SERVER = "SERVER",
UNKNOWN = "UNKNOWN",
}
function classifyError(error: unknown): {
type: UploadErrorType;
message: string;
} {
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes("401") || message.includes("unauthorized")) {
return { type: UploadErrorType.AUTH, message: "Session expired" };
}
if (message.includes("404") || message.includes("not found")) {
return { type: UploadErrorType.NOT_FOUND, message: "Resource not found" };
}
if (message.includes("500") || message.includes("server")) {
return { type: UploadErrorType.SERVER, message: "Server error" };
}
if (message.includes("network") || message.includes("fetch")) {
return { type: UploadErrorType.NETWORK, message: "Network error" };
}
return { type: UploadErrorType.UNKNOWN, message: error.message };
}
return { type: UploadErrorType.UNKNOWN, message: "Upload failed" };
}
/**
* UploadFilesContext - Centralized file upload state management
*
* This context manages:
* - File attachment state (current files attached to input)
* - Active session binding (which session files are associated with)
* - Automatic upload of pending files when session becomes available
* - Automatic fetch of existing attachments when session changes
* - File upload, removal, and clearing operations
*
* Components should:
* - Call `setActiveSession(sessionId)` when session changes
* - Call `uploadFiles(files)` to attach files (uses active session internally)
* - Call `removeFile(fileId)` to remove files (uses active session internally)
* - Read `currentMessageFiles` to display attached files
*/
interface UploadFilesContextValue {
// Current message files (attached to the input bar)
currentMessageFiles: BuildFile[];
setCurrentMessageFiles: Dispatch<SetStateAction<BuildFile[]>>;
// Upload files - returns optimistic files immediately
uploadFiles: (files: File[], sessionId?: string) => Promise<BuildFile[]>;
// Active session ID (set by parent components)
activeSessionId: string | null;
// Remove a file from current message (and delete from sandbox if uploaded)
removeFile: (fileId: string, sessionId?: string) => void;
/**
* Set the active session ID. This triggers:
* - Fetching existing attachments from the new session (if different)
* - Clearing files if navigating to no session
* - Auto-uploading any pending files
*
* Call this when:
* - Session ID changes in URL
* - Pre-provisioned session becomes available
*/
setActiveSession: (sessionId: string | null) => void;
// Clear all current message files
/**
* Upload files to the active session.
* - If session is available: uploads immediately
* - If no session: marks as PENDING (auto-uploads when session available)
*/
uploadFiles: (files: File[]) => Promise<BuildFile[]>;
/**
* Remove a file from the input bar.
* If the file was uploaded, also deletes from the sandbox.
*/
removeFile: (fileId: string) => void;
/**
* Clear all attached files from the input bar.
* Does NOT delete from sandbox (use for form reset).
*/
clearFiles: () => void;
// Check if any files are uploading
hasUploadingFiles: boolean;
// Check if any files are pending upload
hasPendingFiles: boolean;
}
const UploadFilesContext = createContext<UploadFilesContextValue | null>(null);
@@ -97,9 +317,28 @@ export interface UploadFilesProviderProps {
}
export function UploadFilesProvider({ children }: UploadFilesProviderProps) {
// =========================================================================
// State
// =========================================================================
const [currentMessageFiles, setCurrentMessageFiles] = useState<BuildFile[]>(
[]
);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
// =========================================================================
// Refs for race condition protection
// =========================================================================
const isUploadingPendingRef = useRef(false);
const fetchingSessionRef = useRef<string | null>(null);
const prevSessionRef = useRef<string | null>(null);
// Track active deletions to prevent refetch race condition
const activeDeletionsRef = useRef<Set<string>>(new Set());
// =========================================================================
// Derived state
// =========================================================================
const hasUploadingFiles = useMemo(() => {
return currentMessageFiles.some(
@@ -107,16 +346,321 @@ export function UploadFilesProvider({ children }: UploadFilesProviderProps) {
);
}, [currentMessageFiles]);
const hasPendingFiles = useMemo(() => {
return currentMessageFiles.some(
(file) => file.status === UploadFileStatus.PENDING
);
}, [currentMessageFiles]);
// =========================================================================
// Internal operations (not exposed to consumers)
// =========================================================================
/**
* Upload pending files to the given session.
* Internal function - called automatically by effects.
* Reads current files from state internally to avoid stale closures.
*/
const uploadPendingFilesInternal = useCallback(
async (sessionId: string): Promise<void> => {
if (isUploadingPendingRef.current) return;
// Read current files and find pending ones atomically
let pendingFiles: BuildFile[] = [];
setCurrentMessageFiles((prev) => {
pendingFiles = prev.filter(
(f) => f.status === UploadFileStatus.PENDING && f.file
);
// Mark as uploading in the same state update to avoid race conditions
if (pendingFiles.length > 0) {
return prev.map((f) =>
pendingFiles.some((pf) => pf.id === f.id)
? { ...f, status: UploadFileStatus.UPLOADING }
: f
);
}
return prev;
});
if (pendingFiles.length === 0) return;
isUploadingPendingRef.current = true;
try {
// Upload in parallel
const results = await Promise.all(
pendingFiles.map(async (file) => {
try {
const result = await uploadFileApi(sessionId, file.file!);
return { id: file.id, success: true as const, result };
} catch (error) {
const { message } = classifyError(error);
return {
id: file.id,
success: false as const,
errorMessage: message,
};
}
})
);
// Update statuses
setCurrentMessageFiles((prev) =>
prev.map((f) => {
const result = results.find((r) => r.id === f.id);
if (!result) return f;
return result.success
? {
...f,
status: UploadFileStatus.COMPLETED,
path: result.result.path,
name: result.result.filename,
file: undefined, // Clear blob to free memory
}
: {
...f,
status: UploadFileStatus.FAILED,
error: result.errorMessage,
};
})
);
} finally {
isUploadingPendingRef.current = false;
}
},
[]
);
/**
* Fetch existing attachments from the backend.
* Internal function - called automatically by effects.
*/
const fetchExistingAttachmentsInternal = useCallback(
async (sessionId: string, replace: boolean): Promise<void> => {
// Request deduplication
if (fetchingSessionRef.current === sessionId) return;
fetchingSessionRef.current = sessionId;
try {
const listing = await fetchDirectoryListing(sessionId, "attachments");
// Use deterministic IDs based on session and path for stable React keys
const attachments: BuildFile[] = listing.entries
.filter((entry) => !entry.is_directory)
.map((entry) => ({
id: `existing_${sessionId}_${entry.path}`,
name: entry.name,
status: UploadFileStatus.COMPLETED,
file_type: entry.mime_type || "application/octet-stream",
size: entry.size || 0,
created_at: new Date().toISOString(),
path: entry.path,
}));
if (replace) {
// When replacing, preserve any files that are still being processed locally
// (uploading, pending, or recently completed uploads that might not be in
// backend listing yet due to race conditions)
setCurrentMessageFiles((prev) => {
// Keep files that are still in-flight or don't have a path yet
const localOnlyFiles = prev.filter(
(f) =>
f.status === UploadFileStatus.UPLOADING ||
f.status === UploadFileStatus.PENDING ||
f.status === UploadFileStatus.PROCESSING ||
// Keep recently uploaded files (have temp ID, not fetched from backend)
f.id.startsWith("temp_")
);
// Merge: backend attachments + local-only files (avoiding duplicates by path)
const backendPaths = new Set(attachments.map((f) => f.path));
const nonDuplicateLocalFiles = localOnlyFiles.filter(
(f) => !f.path || !backendPaths.has(f.path)
);
return [...attachments, ...nonDuplicateLocalFiles];
});
} else if (attachments.length > 0) {
setCurrentMessageFiles((prev) => {
const existingPaths = new Set(prev.map((f) => f.path));
const newFiles = attachments.filter(
(f) => !existingPaths.has(f.path)
);
return [...prev, ...newFiles];
});
}
} catch (error) {
const { type } = classifyError(error);
if (type !== UploadErrorType.NOT_FOUND) {
console.error(
"[UploadFilesContext] fetchExistingAttachments error:",
error
);
}
if (replace) {
// On error, only clear files that aren't being processed locally
setCurrentMessageFiles((prev) =>
prev.filter(
(f) =>
f.status === UploadFileStatus.UPLOADING ||
f.status === UploadFileStatus.PENDING ||
f.status === UploadFileStatus.PROCESSING ||
f.id.startsWith("temp_")
)
);
}
} finally {
fetchingSessionRef.current = null;
}
},
[]
);
// =========================================================================
// Effects - Automatic state machine transitions
// =========================================================================
/**
* Effect: Handle session changes
*
* When activeSessionId changes:
* - If changed to a DIFFERENT non-null session: fetch attachments (replace mode)
* - If changed to null: do nothing (don't clear - session might be temporarily null during revalidation)
*
* This prevents unnecessary fetches/clears when the focus handler temporarily
* resets the pre-provisioned session state.
*/
useEffect(() => {
const prevSession = prevSessionRef.current;
const currentSession = activeSessionId;
// Only update ref when we have a non-null session (ignore temporary nulls)
if (currentSession) {
// Session changed to a different non-null value
if (currentSession !== prevSession) {
prevSessionRef.current = currentSession;
fetchExistingAttachmentsInternal(currentSession, true);
}
}
// When session becomes null, don't clear files or update ref.
// This handles the case where pre-provisioning temporarily resets on focus.
// Files will be cleared when user actually navigates away or logs out.
}, [activeSessionId, fetchExistingAttachmentsInternal]);
/**
* Effect: Auto-upload pending files when session becomes available
*
* This handles the case where user attaches files before session is ready.
*/
useEffect(() => {
if (activeSessionId && hasPendingFiles) {
uploadPendingFilesInternal(activeSessionId);
}
}, [activeSessionId, hasPendingFiles, uploadPendingFilesInternal]);
/**
* Effect: Refetch attachments after files are cleared
*
* When files are cleared (e.g., after sending a message) but we're still
* on the same session, refetch to restore any backend attachments.
*
* IMPORTANT: Skip refetch if files went to 0 due to active deletions.
* This prevents a race condition where refetch returns the file before
* backend deletion completes, causing the file pill to persist.
*/
const prevFilesLengthRef = useRef(currentMessageFiles.length);
useEffect(() => {
const prevLength = prevFilesLengthRef.current;
const currentLength = currentMessageFiles.length;
prevFilesLengthRef.current = currentLength;
// Files were just cleared (went from >0 to 0)
const filesWereCleared = prevLength > 0 && currentLength === 0;
// Skip refetch if there are active deletions in progress
// This prevents the deleted file from being re-added before backend deletion completes
const hasActiveDeletions = activeDeletionsRef.current.size > 0;
// Refetch if on same session and files were cleared (not deleted)
if (
filesWereCleared &&
activeSessionId &&
prevSessionRef.current === activeSessionId &&
!hasActiveDeletions
) {
fetchExistingAttachmentsInternal(activeSessionId, false);
}
}, [
currentMessageFiles.length,
activeSessionId,
fetchExistingAttachmentsInternal,
]);
// =========================================================================
// Public API
// =========================================================================
/**
* Set the active session. Triggers fetching/clearing as needed.
*/
const setActiveSession = useCallback((sessionId: string | null) => {
setActiveSessionId(sessionId);
}, []);
/**
* Upload files. Uses activeSessionId internally.
* Validates files before upload (size, extension, batch limits).
*/
const uploadFiles = useCallback(
async (files: File[], sessionId?: string): Promise<BuildFile[]> => {
// Create optimistic files
const optimisticFiles = files.map(createOptimisticFile);
async (files: File[]): Promise<BuildFile[]> => {
// Get current files for batch validation
const existingFiles = currentMessageFiles;
// Validate batch constraints first
const batchValidation = validateBatch(files, existingFiles);
if (!batchValidation.valid) {
// Create failed files for all with the batch error
const failedFiles = files.map((f) =>
createFailedFile(f, batchValidation.error!)
);
setCurrentMessageFiles((prev) => [...prev, ...failedFiles]);
return failedFiles;
}
// Validate each file individually and separate valid from invalid
const validFiles: File[] = [];
const failedFiles: BuildFile[] = [];
for (const file of files) {
const validation = validateFile(file);
if (validation.valid) {
validFiles.push(file);
} else {
failedFiles.push(createFailedFile(file, validation.error!));
}
}
// Add failed files immediately
if (failedFiles.length > 0) {
setCurrentMessageFiles((prev) => [...prev, ...failedFiles]);
}
// If no valid files, return early
if (validFiles.length === 0) {
return failedFiles;
}
// Create optimistic files for valid files
const optimisticFiles = validFiles.map(createOptimisticFile);
// Add to current message files immediately
setCurrentMessageFiles((prev) => [...prev, ...optimisticFiles]);
const sessionId = activeSessionId;
if (sessionId) {
// Upload all files in parallel for better performance
// Session available - upload immediately
const uploadPromises = optimisticFiles.map(async (optimisticFile) => {
try {
const result = await uploadFileApi(sessionId, optimisticFile.file!);
@@ -126,22 +670,18 @@ export function UploadFilesProvider({ children }: UploadFilesProviderProps) {
result,
};
} catch (error) {
console.error("File upload failed:", error);
let errorMessage = "Upload failed";
if (error instanceof Error) {
errorMessage = error.message;
}
const { message } = classifyError(error);
return {
id: optimisticFile.id,
success: false as const,
errorMessage,
errorMessage: message,
};
}
});
const results = await Promise.all(uploadPromises);
// Batch update all file statuses at once
// Batch update all file statuses
setCurrentMessageFiles((prev) =>
prev.map((f) => {
const uploadResult = results.find((r) => r.id === f.id);
@@ -153,6 +693,7 @@ export function UploadFilesProvider({ children }: UploadFilesProviderProps) {
status: UploadFileStatus.COMPLETED,
path: uploadResult.result.path,
name: uploadResult.result.filename,
file: undefined, // Clear blob to free memory
};
} else {
return {
@@ -164,8 +705,7 @@ export function UploadFilesProvider({ children }: UploadFilesProviderProps) {
})
);
} else {
// No session yet - mark as PENDING (will upload when session is created)
// The ChatPanel fallback will handle uploading these when the session is ready
// No session yet - mark as PENDING (effect will auto-upload when session available)
setCurrentMessageFiles((prev) =>
prev.map((f) =>
optimisticFiles.some((of) => of.id === f.id)
@@ -175,47 +715,108 @@ export function UploadFilesProvider({ children }: UploadFilesProviderProps) {
);
}
return optimisticFiles;
return [...failedFiles, ...optimisticFiles];
},
[]
[activeSessionId, currentMessageFiles]
);
/**
* Remove a file. Uses activeSessionId internally for sandbox deletion.
*/
const removeFile = useCallback(
(fileId: string, sessionId?: string) => {
// Find the file to check if it has been uploaded
const file = currentMessageFiles.find((f) => f.id === fileId);
(fileId: string) => {
// Track this deletion to prevent refetch race condition
activeDeletionsRef.current.add(fileId);
// If file has a path and sessionId is provided, delete from sandbox
if (file?.path && sessionId) {
deleteFileApi(sessionId, file.path).catch((error) => {
console.error("Failed to delete file from sandbox:", error);
});
}
// Use functional update to get current state and avoid stale closures
let removedFile: BuildFile | null = null;
let removedIndex = -1;
setCurrentMessageFiles((prev) => prev.filter((f) => f.id !== fileId));
setCurrentMessageFiles((prev) => {
const index = prev.findIndex((f) => f.id === fileId);
if (index === -1) return prev;
// Capture file info for potential rollback and backend deletion
const file = prev[index];
if (!file) return prev;
removedFile = file;
removedIndex = index;
// Return filtered array (optimistic removal)
return prev.filter((f) => f.id !== fileId);
});
// After state update, trigger backend deletion if needed
// Use setTimeout to ensure state update has completed
setTimeout(() => {
if (removedFile?.path && activeSessionId) {
const filePath = removedFile.path;
const fileToRestore = removedFile;
const indexToRestore = removedIndex;
deleteFileApi(activeSessionId, filePath)
.then(() => {
// Deletion succeeded - remove from active deletions
activeDeletionsRef.current.delete(fileId);
})
.catch((error) => {
console.error(
"[UploadFilesContext] Failed to delete file from sandbox:",
error
);
// Remove from active deletions
activeDeletionsRef.current.delete(fileId);
// Rollback: restore the file at its original position
setCurrentMessageFiles((prev) => {
// Check if file was already re-added (e.g., by another operation)
if (prev.some((f) => f.id === fileToRestore.id)) return prev;
const newFiles = [...prev];
const insertIndex = Math.min(indexToRestore, newFiles.length);
newFiles.splice(insertIndex, 0, fileToRestore);
return newFiles;
});
});
} else {
// No backend deletion needed - remove from active deletions immediately
activeDeletionsRef.current.delete(fileId);
}
}, 0);
},
[currentMessageFiles]
[activeSessionId]
);
/**
* Clear all files from the input bar.
*/
const clearFiles = useCallback(() => {
setCurrentMessageFiles([]);
}, []);
// =========================================================================
// Context value
// =========================================================================
const value = useMemo<UploadFilesContextValue>(
() => ({
currentMessageFiles,
setCurrentMessageFiles,
activeSessionId,
setActiveSession,
uploadFiles,
removeFile,
clearFiles,
hasUploadingFiles,
hasPendingFiles,
}),
[
currentMessageFiles,
activeSessionId,
setActiveSession,
uploadFiles,
removeFile,
clearFiles,
hasUploadingFiles,
hasPendingFiles,
]
);

View File

@@ -8,6 +8,7 @@ import { CRAFT_SEARCH_PARAM_NAMES } from "@/app/craft/services/searchParams";
import { CRAFT_PATH } from "@/app/craft/v1/constants";
import { getBuildUserPersona } from "@/app/craft/onboarding/constants";
import { useLLMProviders } from "@/lib/hooks/useLLMProviders";
import { checkPreProvisionedSession } from "@/app/craft/services/apiServices";
interface UseBuildSessionControllerProps {
/** Session ID from search params, or null for new session */
@@ -234,8 +235,8 @@ export function useBuildSessionController({
]);
// Effect: Re-validate pre-provisioned session on tab focus (multi-tab support)
// The backend's createSession does "get or create empty session" - it returns
// the same session if still valid, or a new one if consumed by another tab.
// Uses checkPreProvisionedSession API to validate without resetting state,
// which prevents unnecessary cascading effects when session is still valid.
useEffect(() => {
const handleFocus = async () => {
const { preProvisioning } = useBuildSessionStore.getState();
@@ -244,16 +245,41 @@ export function useBuildSessionController({
if (preProvisioning.status === "ready") {
const cachedSessionId = preProvisioning.sessionId;
// Reset to idle and re-provision - backend will return same session if
// still valid, or create new one if it was consumed by another tab
useBuildSessionStore.setState({ preProvisioning: { status: "idle" } });
const newSessionId = await useBuildSessionStore
.getState()
.ensurePreProvisionedSession();
try {
// Check if session is still valid WITHOUT resetting state
const { valid } = await checkPreProvisionedSession(cachedSessionId);
if (newSessionId && newSessionId !== cachedSessionId) {
console.info(
`[PreProvision] Session changed on focus: ${cachedSessionId} -> ${newSessionId}`
if (!valid) {
// Session was consumed by another tab - now reset and re-provision
console.info(
`[PreProvision] Session ${cachedSessionId.slice(
0,
8
)} invalidated on focus, re-provisioning...`
);
useBuildSessionStore.setState({
preProvisioning: { status: "idle" },
});
const newSessionId = await useBuildSessionStore
.getState()
.ensurePreProvisionedSession();
if (newSessionId) {
console.info(
`[PreProvision] Session changed on focus: ${cachedSessionId.slice(
0,
8
)} -> ${newSessionId.slice(0, 8)}`
);
}
}
// If valid, do nothing - keep the current session
} catch (error) {
// On error, log but don't reset - better to keep potentially stale session
// than to cause UI flicker on network blip
console.warn(
"[PreProvision] Failed to validate session on focus:",
error
);
}
}

View File

@@ -553,9 +553,18 @@ export async function deleteFile(
sessionId: string,
path: string
): Promise<void> {
const res = await fetch(`${API_BASE}/sessions/${sessionId}/files/${path}`, {
method: "DELETE",
});
// Encode each path segment individually (spaces, special chars) but preserve slashes
const encodedPath = path
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
const res = await fetch(
`${API_BASE}/sessions/${sessionId}/files/${encodedPath}`,
{
method: "DELETE",
}
);
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));