feat: stream files as binary frames over WebSocket (#659)

Replace base64-in-JSON file transfers with binary WebSocket frames to
unblock the JS thread when assistant markdown messages contain images.
Receive cost (JSON.parse over multi-MB base64) and persist cost (base64
re-encode to disk) both removed.

- Split daemon-client `exploreFileSystem` into `listDirectory` + `readFile`;
  `readFile` returns `{ bytes, mime, size, modifiedAt }`. Encoding stays
  private to the seam.
- New `packages/server/src/shared/binary-frames/` module hosts terminal
  frames (moved from `terminal-stream-protocol.ts`) and new file-transfer
  opcodes (`FileBegin` / `FileChunk` / `FileEnd`) with request-id correlation.
- Add optional `acceptBinary` to `FileExplorerRequestSchema`. New daemons
  emit binary frames when set; legacy JSON path remains for old clients.
  Compat fallback lives only inside `readFile`.
- New `persistAttachmentFromBytes` writes bytes directly: native via
  `expo-file-system` `File.write(Uint8Array)`, web via Blob -> IndexedDB,
  desktop via new `write_attachment_bytes` IPC. `persistAttachmentFromBase64`
  removed.
This commit is contained in:
Mohamed Boudra
2026-05-02 14:02:27 +08:00
committed by GitHub
parent b6c43088fb
commit bb889dae99
44 changed files with 1630 additions and 436 deletions
@@ -14,6 +14,21 @@ const fileSystemMock = vi.hoisted(() => ({
readAsStringAsync: vi.fn(async () => "AAECAw=="),
deleteAsync: vi.fn(async () => {}),
readDirectoryAsync: vi.fn(async () => []),
fileWrite: vi.fn((_uri: string, _content: Uint8Array) => {}),
}));
vi.mock("expo-file-system", () => ({
File: class {
uri: string;
constructor(uri: string) {
this.uri = uri;
}
write(content: Uint8Array) {
fileSystemMock.fileWrite(this.uri, content);
}
},
}));
vi.mock("expo-file-system/legacy", () => ({
@@ -37,9 +52,10 @@ describe("local file attachment store", () => {
fileSystemMock.readAsStringAsync.mockClear();
fileSystemMock.deleteAsync.mockClear();
fileSystemMock.readDirectoryAsync.mockClear();
fileSystemMock.fileWrite.mockClear();
});
it("writes raw base64 sources directly to the managed file path", async () => {
it("writes raw byte sources directly to the managed file path", async () => {
const store = createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "preview-assets",
@@ -50,14 +66,14 @@ describe("local file attachment store", () => {
id: "preview_8_test",
mimeType: "image/png",
fileName: "result.png",
source: { kind: "base64", base64: "AAECAw==" },
source: { kind: "bytes", bytes: new Uint8Array([0, 1, 2, 3]) },
});
expect(fileSystemMock.writeAsStringAsync).toHaveBeenCalledWith(
expect(fileSystemMock.fileWrite).toHaveBeenCalledWith(
"file:///cache/preview-assets/preview_8_test.png",
"AAECAw==",
{ encoding: "base64" },
new Uint8Array([0, 1, 2, 3]),
);
expect(fileSystemMock.writeAsStringAsync).not.toHaveBeenCalled();
expect(attachment).toMatchObject({
id: "preview_8_test",
mimeType: "image/png",
@@ -1,3 +1,4 @@
import { File } from "expo-file-system";
import * as FileSystem from "expo-file-system/legacy";
import {
type AttachmentStore,
@@ -6,7 +7,6 @@ import {
type SaveAttachmentInput,
} from "@/attachments/types";
import {
blobToBase64,
fileUriToPath,
generateAttachmentId,
getFileExtensionFromName,
@@ -45,6 +45,15 @@ async function ensureDirectory(uri: string): Promise<void> {
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
}
async function dataUrlToBytes(dataUrl: string): Promise<Uint8Array> {
const response = await fetch(dataUrl);
return new Uint8Array(await response.arrayBuffer());
}
async function blobToBytes(blob: Blob): Promise<Uint8Array> {
return new Uint8Array(await blob.arrayBuffer());
}
async function writeFromSource(input: {
source: SaveAttachmentInput["source"];
targetUri: string;
@@ -59,30 +68,16 @@ async function writeFromSource(input: {
return;
}
let bytes: Uint8Array;
if (input.source.kind === "data_url") {
const parsed = parseDataUrl(input.source.dataUrl);
const mimeType = normalizeMimeType(parsed.mimeType || input.mimeType);
const base64 = parsed.base64;
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
if (mimeType !== input.mimeType) {
return;
}
return;
bytes = await dataUrlToBytes(input.source.dataUrl);
} else if (input.source.kind === "blob") {
bytes = await blobToBytes(input.source.blob);
} else {
bytes = input.source.bytes;
}
if (input.source.kind === "base64") {
await FileSystem.writeAsStringAsync(input.targetUri, input.source.base64, {
encoding: FileSystem.EncodingType.Base64,
});
return;
}
const base64 = await blobToBase64(input.source.blob);
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
new File(input.targetUri).write(bytes);
}
function attachmentUri(metadata: AttachmentMetadata): string {
+9 -8
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it } from "vitest";
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
import { __setAttachmentStoreForTests } from "./store";
import { encodeAttachmentsForSend, persistAttachmentFromBase64 } from "./service";
import { encodeAttachmentsForSend, persistAttachmentFromBytes } from "./service";
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
return {
@@ -54,19 +54,20 @@ describe("attachment service", () => {
__setAttachmentStoreForTests(null);
});
it("persists raw base64 without requiring a data URL wrapper", async () => {
it("persists raw bytes without requiring a base64 wrapper", async () => {
const store = createRecordingStore();
__setAttachmentStoreForTests(store);
const bytes = new Uint8Array([0, 1, 2, 3]);
const attachment = await persistAttachmentFromBase64({
id: "att_base64",
base64: "AAECAw==",
const attachment = await persistAttachmentFromBytes({
id: "att_bytes",
bytes,
mimeType: "image/png",
fileName: "image.png",
});
expect(attachment).toEqual({
id: "att_base64",
id: "att_bytes",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "att_1",
@@ -76,10 +77,10 @@ describe("attachment service", () => {
});
expect(store.savedSources).toEqual([
{
id: "att_base64",
id: "att_bytes",
mimeType: "image/png",
fileName: "image.png",
source: { kind: "base64", base64: "AAECAw==" },
source: { kind: "bytes", bytes },
},
]);
});
+3 -3
View File
@@ -31,8 +31,8 @@ export async function persistAttachmentFromDataUrl(input: {
});
}
export async function persistAttachmentFromBase64(input: {
base64: string;
export async function persistAttachmentFromBytes(input: {
bytes: Uint8Array;
mimeType?: string;
fileName?: string | null;
id?: string;
@@ -42,7 +42,7 @@ export async function persistAttachmentFromBase64(input: {
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
source: { kind: "base64", base64: input.base64 },
source: { kind: "bytes", bytes: input.bytes },
});
}
+1 -1
View File
@@ -33,7 +33,7 @@ export type UserComposerAttachment = Exclude<ComposerAttachment, { kind: "review
export type WorkspaceComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
export type AttachmentDataSource =
| { kind: "base64"; base64: string }
| { kind: "bytes"; bytes: Uint8Array }
| { kind: "blob"; blob: Blob }
| { kind: "data_url"; dataUrl: string }
| { kind: "file_uri"; uri: string };
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createIndexedDbAttachmentStore } from "./indexeddb-attachment-store";
type Listener = () => void;
class FakeRequest<T = unknown> {
result!: T;
error: Error | null = null;
private listeners = new Map<string, Listener[]>();
addEventListener(event: string, listener: Listener): void {
this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]);
}
emit(event: string): void {
for (const listener of this.listeners.get(event) ?? []) {
listener();
}
}
}
class FakeObjectStore {
constructor(private readonly onPut: (record: unknown) => void) {}
put(record: unknown): FakeRequest<unknown> {
this.onPut(record);
const request = new FakeRequest<unknown>();
queueMicrotask(() => request.emit("success"));
return request;
}
}
class FakeTransaction {
error: Error | null = null;
constructor(private readonly store: FakeObjectStore) {}
objectStore(): FakeObjectStore {
return this.store;
}
addEventListener(): void {}
}
class FakeDatabase {
readonly objectStoreNames = {
contains: () => true,
};
constructor(private readonly store: FakeObjectStore) {}
createObjectStore(): void {}
transaction(): FakeTransaction {
return new FakeTransaction(this.store);
}
close(): void {}
}
describe("indexeddb attachment store", () => {
let storedRecord: unknown;
beforeEach(() => {
storedRecord = null;
Object.defineProperty(globalThis, "indexedDB", {
configurable: true,
value: {
open: () => {
const store = new FakeObjectStore((record) => {
storedRecord = record;
});
const request = new FakeRequest<FakeDatabase>();
request.result = new FakeDatabase(store);
queueMicrotask(() => request.emit("success"));
return request;
},
} as unknown as IDBFactory,
});
});
afterEach(() => {
Reflect.deleteProperty(globalThis, "indexedDB");
});
it("stores raw byte sources as a Blob", async () => {
const store = createIndexedDbAttachmentStore();
const bytes = new Uint8Array([0, 1, 2, 3]);
const attachment = await store.save({
id: "att_bytes",
mimeType: "image/png",
fileName: "image.png",
source: { kind: "bytes", bytes },
});
expect(storedRecord).toEqual({
id: "att_bytes",
blob: new Blob([bytes], { type: "image/png" }),
createdAt: expect.any(Number),
fileName: "image.png",
});
expect(attachment).toMatchObject({
id: "att_bytes",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "att_bytes",
fileName: "image.png",
byteSize: 4,
});
});
});
@@ -74,17 +74,18 @@ function runTx<T>(
});
}
function base64ToBlob(input: { base64: string; mimeType: string }): Blob {
const binary = atob(input.base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: input.mimeType });
}
async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> {
const source = input.source;
if (source.kind === "bytes") {
const mimeType = normalizeMimeType(input.mimeType);
const buffer = new ArrayBuffer(source.bytes.byteLength);
new Uint8Array(buffer).set(source.bytes);
return {
blob: new Blob([buffer], { type: mimeType }),
mimeType,
};
}
if (source.kind === "blob") {
const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type);
const blob =
@@ -105,14 +106,6 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
};
}
if (source.kind === "base64") {
const mimeType = normalizeMimeType(input.mimeType);
return {
blob: base64ToBlob({ base64: source.base64, mimeType }),
mimeType,
};
}
const response = await fetch(source.uri);
const blob = await response.blob();
const mimeType = normalizeMimeType(input.mimeType ?? blob.type);
+32 -22
View File
@@ -1,5 +1,6 @@
import React, { useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@server/client/daemon-client";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
import {
ActivityIndicator,
@@ -27,8 +28,9 @@ import { isWeb } from "@/constants/platform";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBase64 } from "@/attachments/service";
import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
interface CodeLineProps {
tokens: HighlightToken[];
@@ -65,30 +67,34 @@ function formatFileSize({ size }: { size: number }): string {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
async function createFilePanePreview(file: ExplorerFile | null): Promise<{
async function createFilePanePreview(file: FileReadResult | null): Promise<{
file: ExplorerFile | null;
imageAttachment: AttachmentMetadata | null;
}> {
if (!file || file.kind !== "image" || !file.content) {
return { file, imageAttachment: null };
if (!file) {
return { file: null, imageAttachment: null };
}
const { content: _content, ...imageFile } = file;
const imageAttachment = await persistAttachmentFromBase64({
const explorerFile = explorerFileFromReadResult(file);
if (file.kind !== "image") {
return { file: explorerFile, imageAttachment: null };
}
const imageAttachment = await persistAttachmentFromBytes({
id: createPreviewAttachmentId({
mimeType: file.mimeType ?? "image/png",
mimeType: file.mime,
path: file.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.content.length,
contentLength: file.bytes.byteLength,
}),
base64: file.content,
mimeType: file.mimeType,
bytes: file.bytes,
mimeType: file.mime,
fileName: getFileNameFromPath(file.path),
});
return {
file: imageFile,
file: explorerFile,
imageAttachment,
};
}
@@ -358,17 +364,21 @@ export function FilePane({
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
return { file: null as ExplorerFile | null, error: "Host is not connected" };
}
const payload = await client.exploreFileSystem(
normalizedWorkspaceRoot,
normalizedFilePath,
"file",
);
const preview = await createFilePanePreview(payload.file ?? null);
return {
file: preview.file,
imageAttachment: preview.imageAttachment,
error: payload.error ?? null,
};
try {
const file = await client.readFile(normalizedWorkspaceRoot, normalizedFilePath);
const preview = await createFilePanePreview(file);
return {
file: preview.file,
imageAttachment: preview.imageAttachment,
error: null,
};
} catch (error) {
return {
file: null,
imageAttachment: null,
error: error instanceof Error ? error.message : "Failed to load file",
};
}
},
staleTime: 5_000,
refetchOnMount: true,
+8 -12
View File
@@ -98,7 +98,7 @@ import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBase64, persistAttachmentFromDataUrl } from "@/attachments/service";
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
export type { InlinePathTarget } from "@/utils/inline-path";
@@ -760,25 +760,21 @@ function AssistantMarkdownImage({
return null;
}
const payload = await client.exploreFileSystem(resolution.cwd, resolution.path, "file");
if (payload.error) {
throw new Error(payload.error);
}
const file = payload.file;
if (!file || file.kind !== "image" || !file.content) {
const file = await client.readFile(resolution.cwd, resolution.path);
if (file.kind !== "image") {
throw new Error("Image preview unavailable.");
}
return await persistAttachmentFromBase64({
return await persistAttachmentFromBytes({
id: createPreviewAttachmentId({
mimeType: file.mimeType ?? "image/png",
mimeType: file.mime,
path: file.path || resolution.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.content.length,
contentLength: file.bytes.byteLength,
}),
base64: file.content,
mimeType: file.mimeType,
bytes: file.bytes,
mimeType: file.mime,
fileName: getFileNameFromPath(file.path || resolution.path),
});
},
@@ -4,6 +4,7 @@ import { createDesktopAttachmentStore } from "./desktop-attachment-store";
const {
copyDesktopAttachmentFileMock,
writeDesktopAttachmentBase64Mock,
writeDesktopAttachmentBytesMock,
deleteDesktopAttachmentFileMock,
garbageCollectDesktopAttachmentFilesMock,
readDesktopFileBase64Mock,
@@ -18,6 +19,10 @@ const {
path: "/managed/att_2.png",
byteSize: 4,
})),
writeDesktopAttachmentBytesMock: vi.fn(async () => ({
path: "/managed/att_bytes.png",
byteSize: 4,
})),
deleteDesktopAttachmentFileMock: vi.fn(async () => true),
garbageCollectDesktopAttachmentFilesMock: vi.fn(async () => 0),
readDesktopFileBase64Mock: vi.fn(async () => "AAECAw=="),
@@ -28,6 +33,7 @@ const {
vi.mock("./desktop-file-commands", () => ({
copyDesktopAttachmentFile: copyDesktopAttachmentFileMock,
writeDesktopAttachmentBase64: writeDesktopAttachmentBase64Mock,
writeDesktopAttachmentBytes: writeDesktopAttachmentBytesMock,
deleteDesktopAttachmentFile: deleteDesktopAttachmentFileMock,
garbageCollectDesktopAttachmentFiles: garbageCollectDesktopAttachmentFilesMock,
}));
@@ -42,6 +48,7 @@ describe("desktop attachment store", () => {
beforeEach(() => {
copyDesktopAttachmentFileMock.mockClear();
writeDesktopAttachmentBase64Mock.mockClear();
writeDesktopAttachmentBytesMock.mockClear();
deleteDesktopAttachmentFileMock.mockClear();
garbageCollectDesktopAttachmentFilesMock.mockClear();
readDesktopFileBase64Mock.mockClear();
@@ -86,28 +93,30 @@ describe("desktop attachment store", () => {
});
});
it("saves raw base64 sources via desktop filesystem writes", async () => {
it("saves raw byte sources via desktop filesystem writes", async () => {
const store = createDesktopAttachmentStore();
const bytes = new Uint8Array([0, 1, 2, 3]);
const attachment = await store.save({
id: "att_base64",
id: "att_bytes",
mimeType: "image/png",
fileName: "inline.png",
source: {
kind: "base64",
base64: "AAECAw==",
kind: "bytes",
bytes,
},
});
expect(writeDesktopAttachmentBase64Mock).toHaveBeenCalledWith({
attachmentId: "att_base64",
base64: "AAECAw==",
expect(writeDesktopAttachmentBytesMock).toHaveBeenCalledWith({
attachmentId: "att_bytes",
bytes,
extension: ".png",
});
expect(writeDesktopAttachmentBase64Mock).not.toHaveBeenCalled();
expect(attachment).toMatchObject({
id: "att_base64",
id: "att_bytes",
mimeType: "image/png",
storageType: "desktop-file",
storageKey: "/managed/att_2.png",
storageKey: "/managed/att_bytes.png",
fileName: "inline.png",
byteSize: 4,
});
@@ -12,6 +12,7 @@ import {
deleteDesktopAttachmentFile,
garbageCollectDesktopAttachmentFiles,
writeDesktopAttachmentBase64,
writeDesktopAttachmentBytes,
} from "@/desktop/attachments/desktop-file-commands";
import {
readDesktopFileBase64,
@@ -132,6 +133,32 @@ async function saveDesktopAttachmentFromBase64(input: {
});
}
async function saveDesktopAttachmentFromBytes(input: {
id: string;
bytes: Uint8Array;
mimeType: string;
fileName?: string | null;
}): Promise<AttachmentMetadata> {
const extension = extensionForAttachment({
fileName: input.fileName,
mimeType: input.mimeType,
});
const result = await writeDesktopAttachmentBytes({
attachmentId: input.id,
bytes: input.bytes,
extension,
});
return toDesktopMetadata({
id: input.id,
mimeType: input.mimeType,
storagePath: result.path,
byteSize: result.byteSize,
fileName: input.fileName,
});
}
function assertDesktopAttachment(attachment: AttachmentMetadata): void {
if (attachment.storageType !== "desktop-file") {
throw new Error(`Unsupported desktop attachment storage type '${attachment.storageType}'.`);
@@ -166,11 +193,11 @@ export function createDesktopAttachmentStore(): AttachmentStore {
});
}
if (input.source.kind === "base64") {
if (input.source.kind === "bytes") {
const mimeType = normalizeMimeType(input.mimeType);
return await saveDesktopAttachmentFromBase64({
return await saveDesktopAttachmentFromBytes({
id,
base64: input.source.base64,
bytes: input.source.bytes,
mimeType,
fileName,
});
@@ -17,6 +17,18 @@ export async function writeDesktopAttachmentBase64(input: {
});
}
export async function writeDesktopAttachmentBytes(input: {
attachmentId: string;
bytes: Uint8Array;
extension?: string | null;
}): Promise<AttachmentFileResult> {
return await invokeDesktopCommand<AttachmentFileResult>("write_attachment_bytes", {
attachmentId: input.attachmentId,
bytes: input.bytes,
extension: input.extension ?? null,
});
}
export async function copyDesktopAttachmentFile(input: {
attachmentId: string;
sourcePath: string;
@@ -1,14 +1,10 @@
import type { AttachmentMetadata } from "@/attachments/types";
import { fileUriToPath } from "@/attachments/utils";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
import { Buffer } from "buffer";
function base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
return new Uint8Array(Buffer.from(base64, "base64"));
}
const activeObjectUrls = new Set<string>();
@@ -0,0 +1,15 @@
import type { FileReadResult } from "@server/client/daemon-client";
import type { ExplorerFile } from "@/stores/session-store";
export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile {
const isText = file.kind === "text";
return {
path: file.path,
kind: file.kind,
encoding: isText ? "utf-8" : "none",
content: isText ? new TextDecoder().decode(file.bytes) : undefined,
mimeType: file.mime,
size: file.size,
modifiedAt: file.modifiedAt,
};
}
@@ -1,5 +1,6 @@
import { useCallback, useMemo } from "react";
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
function createExplorerState(): AgentFileExplorerState {
return {
@@ -130,26 +131,20 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
}
try {
const payload = await client.exploreFileSystem(
normalizedWorkspaceRoot,
normalizedPath,
"list",
);
const directory = await client.listDirectory(normalizedWorkspaceRoot, normalizedPath);
updateExplorerState((state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: payload.error ?? null,
lastError: null,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
if (!payload.error && payload.directory) {
const directories = new Map(state.directories);
directories.set(payload.directory.path, payload.directory);
nextState.directories = directories;
}
const directories = new Map(state.directories);
directories.set(directory.path, directory);
nextState.directories = directories;
return nextState;
});
@@ -201,34 +196,29 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
}
try {
const payload = await client.exploreFileSystem(
normalizedWorkspaceRoot,
normalizedPath,
"file",
);
const file = await client.readFile(normalizedWorkspaceRoot, normalizedPath);
updateExplorerState((state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: null,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
if (!payload.error && payload.file) {
const files = new Map(state.files);
files.set(payload.file.path, payload.file);
nextState.files = files;
} else if (payload.error) {
nextState.lastError = payload.error;
}
const files = new Map(state.files);
const explorerFile = explorerFileFromReadResult(file);
files.set(explorerFile.path, explorerFile);
nextState.files = files;
return nextState;
});
} catch {
} catch (error) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: error instanceof Error ? error.message : "Failed to load file preview",
pendingRequest: null,
}));
}
@@ -31,7 +31,7 @@ function createTestStore(): AttachmentStore {
} else if (input.source.kind === "file_uri") {
byteSize = input.source.uri.length;
} else {
byteSize = input.source.base64.length;
byteSize = input.source.bytes.byteLength;
}
return {
id,