Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 23 additions & 17 deletions packages/components/src/components/chat/chat-landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1290,6 +1290,28 @@ function WorkspaceChatLanding({
resetSessionId: resetDraftSessionId,
} = useChatLandingDraftSession();
const attachmentInputRef = useRef<HTMLInputElement>(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,
Expand All @@ -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<string | null>(null);

Expand Down
3 changes: 3 additions & 0 deletions packages/components/src/hooks/use-chat-landing-file-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions packages/components/src/hooks/use-chat-landing-image-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -64,6 +72,7 @@ export function useChatLandingImageDraft(args: {
projectKind,
sessionId: draftSessionId,
ensureSessionId,
degradeToFileAttachments,
} = args;
const postHog = usePostHog();
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
Expand All @@ -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[]) => {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -225,6 +274,8 @@ export function useChatLandingImageDraft(args: {
},
[
authToken,
degradeToFileAttachments,
imageStoredAsLocalFileLabel,
imageUploadFailedLabel,
imageUploadMissingAuthLabel,
postHog,
Expand Down