diff --git a/packages/components/src/components/chat/chat-landing.tsx b/packages/components/src/components/chat/chat-landing.tsx index 369bb4dcd..4ea9200fe 100644 --- a/packages/components/src/components/chat/chat-landing.tsx +++ b/packages/components/src/components/chat/chat-landing.tsx @@ -1290,6 +1290,28 @@ function WorkspaceChatLanding({ resetSessionId: resetDraftSessionId, } = useChatLandingDraftSession(); const attachmentInputRef = useRef(null); + // The file draft is declared FIRST because the image draft degrades into it: + // with no cloud token there is no image upload to attempt, and this draft can + // still hand the bytes to the machine over its local transport. It reads + // nothing from the image draft, so the order costs nothing. + const { + fileItems, + hasBlockingFiles, + hasUploadedFiles, + canAddMoreFiles, + canSendFileLocally, + addFiles: addFileAttachments, + handleRemoveFile, + handleRetryFile, + clearPendingFiles, + buildFileInputBlocks, + } = useChatLandingFileDraft({ + workspaceId: (workspaceId as WorkspaceId | null) ?? null, + authToken, + machineId: selectedMachineId, + sessionId: draftSessionId, + ensureSessionId: ensureDraftSessionId, + }); const { imageItems, hasBlockingImages, @@ -1308,23 +1330,7 @@ function WorkspaceChatLanding({ projectKind: contextType === 'chat' ? null : contextType, sessionId: draftSessionId, ensureSessionId: ensureDraftSessionId, - }); - const { - fileItems, - hasBlockingFiles, - hasUploadedFiles, - canAddMoreFiles, - addFiles: addFileAttachments, - handleRemoveFile, - handleRetryFile, - clearPendingFiles, - buildFileInputBlocks, - } = useChatLandingFileDraft({ - workspaceId: (workspaceId as WorkspaceId | null) ?? null, - authToken, - machineId: selectedMachineId, - sessionId: draftSessionId, - ensureSessionId: ensureDraftSessionId, + degradeToFileAttachments: canSendFileLocally ? addFileAttachments : undefined, }); const lastAppliedResetDraftKeyRef = useRef(null); diff --git a/packages/components/src/hooks/use-chat-landing-file-draft.ts b/packages/components/src/hooks/use-chat-landing-file-draft.ts index a62804b3e..d4d3e1a03 100644 --- a/packages/components/src/hooks/use-chat-landing-file-draft.ts +++ b/packages/components/src/hooks/use-chat-landing-file-draft.ts @@ -375,6 +375,9 @@ export function useChatLandingFileDraft(args: { hasBlockingFiles, hasUploadedFiles, canAddMoreFiles: pendingFiles.length < SESSION_FILE_MAX_COUNT, + /** Whether this draft can stage bytes with no cloud token. The image draft + * beside it asks before degrading an image it cannot upload into a file. */ + canSendFileLocally, addFiles: handleAddFiles, handleRemoveFile, handleRetryFile, diff --git a/packages/components/src/hooks/use-chat-landing-image-draft.ts b/packages/components/src/hooks/use-chat-landing-image-draft.ts index 27ac25dd8..653f16db9 100644 --- a/packages/components/src/hooks/use-chat-landing-image-draft.ts +++ b/packages/components/src/hooks/use-chat-landing-image-draft.ts @@ -55,6 +55,14 @@ export function useChatLandingImageDraft(args: { projectKind: 'github' | 'local' | null; sessionId: SessionId | null; ensureSessionId: () => SessionId; + /** + * Stages files on the landing's sibling FILE draft, which owns a local + * transport that needs no cloud token. Supplied only while that transport is + * available; an image that cannot be uploaded is handed to it as a file + * attachment, the same degrade `session-chat-input-area.tsx` performs when + * both state machines live in one component. + */ + degradeToFileAttachments?: (files: File[]) => void; }) { const { t } = useTranslation(); const { @@ -64,6 +72,7 @@ export function useChatLandingImageDraft(args: { projectKind, sessionId: draftSessionId, ensureSessionId, + degradeToFileAttachments, } = args; const postHog = usePostHog(); const [pendingImages, setPendingImages] = useState([]); @@ -81,6 +90,10 @@ export function useChatLandingImageDraft(args: { 'sessions.imageSelectionSkipped', 'Some images were not added' ); + const imageStoredAsLocalFileLabel = t( + 'sessions.imageStoredAsLocalFile', + 'Image upload is offline; added as a pending file attachment.' + ); const showImageSelectionIssues = useCallback( (issues: string[]) => { @@ -135,6 +148,42 @@ export function useChatLandingImageDraft(args: { const startUpload = useCallback( async (localId: string, file: File, sessionId: SessionId) => { + // Offline degrade-to-file: with no cloud token there is no image upload to + // attempt, but the sibling FILE draft can still hand the bytes to the + // machine over its local transport. So the image becomes a pending file + // attachment there, exactly as `session-chat-input-area.tsx` degrades one + // it could not upload (toast `sessions.imageStoredAsLocalFile`) — the two + // state machines are two hooks here rather than one component, so the + // move across them is a call instead of a `setState`. + // + // It runs BEFORE the cloud-credential guard, because that transport needs + // no cloud token: a local-only composition has none, and would otherwise + // fail every landing image at a check for credentials it never uses. + if (!authToken && workspaceId && degradeToFileAttachments) { + setPendingImages((prev) => { + const removed = prev.find((image) => image.localId === localId); + if (removed) { + URL.revokeObjectURL(removed.previewUrl); + } + return prev.filter((image) => image.localId !== localId); + }); + degradeToFileAttachments([file]); + toast.info(imageStoredAsLocalFileLabel); + capturePostHogEvent(postHog, 'session/image_upload_failed', { + channel: 'web', + entrypoint: 'chat_landing', + actor: 'user', + workspace_id: workspaceId, + session_id: sessionId, + image_count: 1, + total_size_bytes: file.size, + project_kind: projectKind, + failure_reason: 'missing_auth', + local_file_fallback: true, + }); + return; + } + if (!workspaceId || !authToken) { capturePostHogEvent(postHog, 'session/image_upload_failed', { channel: 'web', @@ -225,6 +274,8 @@ export function useChatLandingImageDraft(args: { }, [ authToken, + degradeToFileAttachments, + imageStoredAsLocalFileLabel, imageUploadFailedLabel, imageUploadMissingAuthLabel, postHog,