Skip to content
Open
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
89 changes: 89 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useStore } from "../store";
import {
MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
buildExpiredTerminalContextToastCopy,
buildTurnImageAttachments,
createLocalDispatchSnapshot,
deriveComposerSendState,
hasServerAcknowledgedLocalDispatch,
Expand Down Expand Up @@ -77,6 +78,94 @@ describe("buildExpiredTerminalContextToastCopy", () => {
});
});

describe("buildTurnImageAttachments", () => {
it("uses the live file bytes when the file is still readable", async () => {
const image = {
type: "image" as const,
id: "image-1",
name: "simulator_screenshot_123.png",
mimeType: "image/png",
sizeBytes: 4,
previewUrl: "blob:image-1",
file: new File(["live"], "simulator_screenshot_123.png", { type: "image/png" }),
};

await expect(
buildTurnImageAttachments({
images: [image],
readFile: vi.fn(async () => "data:image/png;base64,bGl2ZQ=="),
}),
).resolves.toEqual([
{
type: "image",
name: "simulator_screenshot_123.png",
mimeType: "image/png",
sizeBytes: 4,
dataUrl: "data:image/png;base64,bGl2ZQ==",
},
]);
});

it("falls back to persisted draft bytes when the live file is gone", async () => {
const image = {
type: "image" as const,
id: "image-1",
name: "simulator_screenshot_123.png",
mimeType: "image/png",
sizeBytes: 4,
previewUrl: "blob:image-1",
file: new File(["live"], "simulator_screenshot_123.png", { type: "image/png" }),
};

await expect(
buildTurnImageAttachments({
images: [image],
persistedAttachments: [
{
id: "image-1",
name: "simulator_screenshot_123.png",
mimeType: "image/png",
sizeBytes: 4,
dataUrl: "data:image/png;base64,c2F2ZWQ=",
},
],
readFile: vi.fn(async () => {
throw new Error("ENOENT");
}),
}),
).resolves.toEqual([
{
type: "image",
name: "simulator_screenshot_123.png",
mimeType: "image/png",
sizeBytes: 4,
dataUrl: "data:image/png;base64,c2F2ZWQ=",
},
]);
});

it("still fails when neither the live file nor a persisted draft copy is available", async () => {
const image = {
type: "image" as const,
id: "image-1",
name: "simulator_screenshot_123.png",
mimeType: "image/png",
sizeBytes: 4,
previewUrl: "blob:image-1",
file: new File(["live"], "simulator_screenshot_123.png", { type: "image/png" }),
};

await expect(
buildTurnImageAttachments({
images: [image],
readFile: vi.fn(async () => {
throw new Error("ENOENT");
}),
}),
).rejects.toThrow("ENOENT");
});
});

describe("reconcileMountedTerminalThreadIds", () => {
it("keeps previously mounted open threads and adds the active open thread", () => {
expect(
Expand Down
46 changes: 44 additions & 2 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts";
import {
ProjectId,
type ModelSelection,
type ThreadId,
type TurnId,
type UploadChatAttachment,
} from "@t3tools/contracts";
import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types";
import { randomUUID } from "~/lib/utils";
import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
import {
type ComposerImageAttachment,
type DraftThreadState,
type PersistedComposerImageAttachment,
} from "../composerDraftStore";
import { Schema } from "effect";
import { useStore } from "../store";
import {
Expand Down Expand Up @@ -129,6 +139,38 @@ export function readFileAsDataUrl(file: File): Promise<string> {
});
}

export async function buildTurnImageAttachments(input: {
images: ReadonlyArray<ComposerImageAttachment>;
persistedAttachments?: ReadonlyArray<PersistedComposerImageAttachment>;
readFile?: (file: File) => Promise<string>;
}): Promise<UploadChatAttachment[]> {
const persistedAttachmentById = new Map(
(input.persistedAttachments ?? []).map((attachment) => [attachment.id, attachment] as const),
);
const readFile = input.readFile ?? readFileAsDataUrl;
return await Promise.all(
input.images.map(async (image) => {
let dataUrl: string;
try {
dataUrl = await readFile(image.file);
} catch (error) {
const persistedAttachment = persistedAttachmentById.get(image.id);
if (!persistedAttachment) {
throw error;
}
dataUrl = persistedAttachment.dataUrl;
}
return {
type: "image" as const,
name: image.name,
mimeType: image.mimeType,
sizeBytes: image.sizeBytes,
dataUrl,
};
}),
);
}

export function buildTemporaryWorktreeBranchName(): string {
// Keep the 8-hex suffix shape for backend temporary-branch detection.
const token = randomUUID().slice(0, 8).toLowerCase();
Expand Down
22 changes: 13 additions & 9 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
import {
MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
buildExpiredTerminalContextToastCopy,
buildTurnImageAttachments,
buildLocalDraftThread,
buildTemporaryWorktreeBranchName,
cloneComposerImageForRetry,
Expand Down Expand Up @@ -2933,6 +2934,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) });

const composerImagesSnapshot = [...composerImages];
const persistedComposerAttachmentsSnapshot = [...composerDraft.persistedAttachments];
const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];
const messageTextForSend = appendTerminalContextsToPrompt(
promptForSend,
Expand All @@ -2947,15 +2949,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
effort: selectedPromptEffort,
text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,
});
const turnAttachmentsPromise = Promise.all(
composerImagesSnapshot.map(async (image) => ({
type: "image" as const,
name: image.name,
mimeType: image.mimeType,
sizeBytes: image.sizeBytes,
dataUrl: await readFileAsDataUrl(image.file),
})),
);
const turnAttachmentsPromise = buildTurnImageAttachments({
images: composerImagesSnapshot,
persistedAttachments: persistedComposerAttachmentsSnapshot,
readFile: readFileAsDataUrl,
});
const optimisticAttachments = composerImagesSnapshot.map((image) => ({
type: "image" as const,
id: image.id,
Expand Down Expand Up @@ -3114,6 +3112,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
setPrompt(promptForSend);
setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length));
addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry));
if (persistedComposerAttachmentsSnapshot.length > 0) {
syncComposerDraftPersistedAttachments(
threadIdForSend,
persistedComposerAttachmentsSnapshot,
);
}
addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot);
setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length));
}
Expand Down
Loading