Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
23 changes: 2 additions & 21 deletions src/browser/features/ChatInput/AttachFileButton.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,11 @@
/**
* Attach file button that opens a native picker.
* Images and PDFs attach natively. When staging is available (open workspace),
* any other file type is accepted and saved into the workspace.
*/

import React, { useRef } from "react";
import { Paperclip } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/Tooltip/Tooltip";
import { cn } from "@/common/lib/utils";

/** Picker filter for composers without workspace staging (creation/scratch). */
const PROVIDER_FILE_ACCEPT = "image/*,.svg,.pdf";

interface AttachFileButtonProps {
onFiles: (files: File[]) => void;
disabled?: boolean;
canStageFiles: boolean;
}

export const AttachFileButton: React.FC<AttachFileButtonProps> = (props) => {
Expand Down Expand Up @@ -55,23 +45,14 @@ export const AttachFileButton: React.FC<AttachFileButtonProps> = (props) => {
</button>
</TooltipTrigger>
<TooltipContent>
{props.canStageFiles ? (
<>
<strong>Attach any file</strong>. Images and PDFs attach directly. Other files are
saved to the workspace.
</>
) : (
<>
<strong>Attach file</strong>: images, SVGs, PDFs
</>
)}
<strong>Attach any file</strong>. Images and PDFs attach directly. Other files are saved
to the workspace.
</TooltipContent>
</Tooltip>
{/* Kept outside Tooltip to avoid stray DOM children. */}
<input
ref={inputRef}
type="file"
accept={props.canStageFiles ? undefined : PROVIDER_FILE_ACCEPT}
multiple
className="hidden"
onChange={handleChange}
Expand Down
20 changes: 18 additions & 2 deletions src/browser/features/ChatInput/ChatAttachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,23 @@ export interface StagedChatAttachment {
stagedPath: string;
}

export type ChatAttachment = ProviderChatAttachment | StagedChatAttachment;
/**
* Non-provider file held in memory (bytes included) by a creation composer
* until a workspace exists to stage it into.
*/
export interface PendingFileChatAttachment {
kind: "pending-file";
id: string;
mediaType: string;
filename: string;
sizeBytes: number;
dataBase64: string;
}

export type ChatAttachment =
| ProviderChatAttachment
| StagedChatAttachment
| PendingFileChatAttachment;

interface ChatAttachmentsProps {
attachments: ChatAttachment[];
Expand Down Expand Up @@ -83,7 +99,7 @@ export const ChatAttachments: React.FC<ChatAttachmentsProps> = (props) => {
const label =
attachment.filename ?? (baseMediaType === "application/pdf" ? "PDF" : baseMediaType);
const detail =
attachment.kind === "staged"
attachment.kind === "staged" || attachment.kind === "pending-file"
? `workspace file • ${formatBytes(attachment.sizeBytes)}`
: null;

Expand Down
26 changes: 26 additions & 0 deletions src/browser/features/ChatInput/draftAttachmentsStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,32 @@ describe("draftAttachmentsStorage", () => {
).toEqual([]);
});

test("parsePersistedChatAttachments round-trips pending files with base64 bytes", () => {
const pendingFile = {
kind: "pending-file" as const,
id: "pending-1",
mediaType: "text/markdown",
filename: "notes.md",
sizeBytes: 8,
dataBase64: "bWFya2Rvd24=",
};
expect(parsePersistedChatAttachments([pendingFile])).toEqual([pendingFile]);
});

test("parsePersistedChatAttachments self-heals invalid pending-file records", () => {
expect(
parsePersistedChatAttachments([
{
kind: "pending-file",
id: "pending-1",
mediaType: "text/markdown",
filename: "notes.md",
sizeBytes: 8,
},
])
).toEqual([]);
});

test("estimatePersistedChatAttachmentsChars matches JSON length", () => {
const attachments = [
{
Expand Down
36 changes: 36 additions & 0 deletions src/browser/features/ChatInput/draftAttachmentsStorage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { ChatAttachment } from "@/browser/features/ChatInput/ChatAttachments";
import { readPersistedState } from "@/browser/hooks/usePersistedState";

/** Attachment drafts above this JSON size stay memory-only (localStorage quota). */
export const MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS = 4_000_000;

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
Expand Down Expand Up @@ -43,6 +46,27 @@ function isStagedChatAttachment(value: unknown): value is {
);
}

function isPendingFileChatAttachment(value: unknown): value is {
kind: "pending-file";
id: string;
mediaType: string;
filename: string;
sizeBytes: number;
dataBase64: string;
} {
if (!isRecord(value)) return false;
return (
value.kind === "pending-file" &&
typeof value.id === "string" &&
typeof value.mediaType === "string" &&
typeof value.filename === "string" &&
typeof value.sizeBytes === "number" &&
Number.isInteger(value.sizeBytes) &&
value.sizeBytes >= 0 &&
typeof value.dataBase64 === "string"
);
}

export function parsePersistedChatAttachments(raw: unknown): ChatAttachment[] {
if (!Array.isArray(raw)) {
return [];
Expand Down Expand Up @@ -73,6 +97,18 @@ export function parsePersistedChatAttachments(raw: unknown): ChatAttachment[] {
continue;
}

if (isPendingFileChatAttachment(item)) {
attachments.push({
kind: "pending-file",
id: item.id,
mediaType: item.mediaType,
filename: item.filename,
sizeBytes: item.sizeBytes,
dataBase64: item.dataBase64,
});
continue;
}

return [];
}

Expand Down
Loading
Loading