diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 717326b9e..1a1cb2c01 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -101,7 +101,6 @@ import { ChevronLeftIcon, ChevronRightIcon, CircleAlertIcon, - ImagePlusIcon, ListTodoIcon, LockIcon, LockOpenIcon, @@ -389,11 +388,19 @@ export default function ChatView({ threadId }: ChatViewProps) { const prompt = composerDraft.prompt; const composerAttachments = composerDraft.attachments; const composerImageAttachments = useMemo( - () => composerAttachments.filter((attachment) => attachment.type === "image"), + () => + composerAttachments.filter( + (attachment): attachment is Extract => + attachment.type === "image", + ), [composerAttachments], ); const composerFileAttachments = useMemo( - () => composerAttachments.filter((attachment) => attachment.type === "file"), + () => + composerAttachments.filter( + (attachment): attachment is Extract => + attachment.type === "file", + ), [composerAttachments], ); const composerTerminalContexts = composerDraft.terminalContexts; @@ -4748,7 +4755,7 @@ export default function ChatView({ threadId }: ChatViewProps) { : showPlanFollowUpPrompt && activeProposedPlan ? "Add feedback to refine the plan, or leave this blank to implement it" : phase === "disconnected" - ? "Ask for follow-up changes or attach images" + ? "Ask for follow-up changes or attach files" : "Ask anything, @tag files/folders, or use / to show available commands" } disabled={isConnecting || isComposerApprovalState || isRemoteActionBlocked} @@ -4919,8 +4926,8 @@ export default function ChatView({ threadId }: ChatViewProps) { type="button" className="text-muted-foreground/70 hover:text-foreground/80" onClick={openFilePicker} - title="Attach images" - aria-label="Attach images" + title="Attach files" + aria-label="Attach files" > diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx index 1cf17aad2..4b6e40b37 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx @@ -21,8 +21,8 @@ async function mountPicker(props?: { >["draftsByThreadId"]; draftsByThreadId[threadId] = { prompt: props?.prompt ?? "", - images: [], - nonPersistedImageIds: [], + attachments: [], + nonPersistedAttachmentIds: [], persistedAttachments: [], terminalContexts: [], provider: "claudeAgent", diff --git a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx index 5d43589b9..5a53bd64d 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx @@ -18,8 +18,8 @@ async function mountPicker(props: { >["draftsByThreadId"]; draftsByThreadId[threadId] = { prompt: "", - images: [], - nonPersistedImageIds: [], + attachments: [], + nonPersistedAttachmentIds: [], persistedAttachments: [], terminalContexts: [], provider: "codex", diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index ce4a3cefa..3a2f22a30 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -23,8 +23,8 @@ async function mountMenu(props?: { >["draftsByThreadId"]; draftsByThreadId[threadId] = { prompt: props?.prompt ?? "", - images: [], - nonPersistedImageIds: [], + attachments: [], + nonPersistedAttachmentIds: [], persistedAttachments: [], terminalContexts: [], provider, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1827ad719..426f35bb4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -387,8 +387,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ row.message.role === "user" && (() => { const userAttachments = row.message.attachments ?? []; - const userImages = userAttachments.filter((attachment) => attachment.type === "image"); - const userFiles = userAttachments.filter((attachment) => attachment.type === "file"); + const userImages = userAttachments.filter( + ( + attachment, + ): attachment is Extract<(typeof userAttachments)[number], { type: "image" }> => + attachment.type === "image", + ); + const userFiles = userAttachments.filter( + ( + attachment, + ): attachment is Extract<(typeof userAttachments)[number], { type: "file" }> => + attachment.type === "file", + ); const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); const terminalContexts = displayedUserMessage.contexts; const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index b2dc49aea..2b8729887 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -72,7 +72,7 @@ function resetComposerDraftStore() { }); } -describe("composerDraftStore addImages", () => { +describe("composerDraftStore addAttachments", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; let revokeSpy: ReturnType void>>; @@ -106,10 +106,10 @@ describe("composerDraftStore addImages", () => { lastModified: 12345, }); - useComposerDraftStore.getState().addImages(threadId, [first, duplicate]); + useComposerDraftStore.getState().addAttachments(threadId, [first, duplicate]); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; - expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]); + expect(draft?.attachments.map((image) => image.id)).toEqual(["img-1"]); expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate"); }); @@ -131,11 +131,11 @@ describe("composerDraftStore addImages", () => { lastModified: 999, }); - useComposerDraftStore.getState().addImage(threadId, first); - useComposerDraftStore.getState().addImage(threadId, duplicateLater); + useComposerDraftStore.getState().addAttachment(threadId, first); + useComposerDraftStore.getState().addAttachment(threadId, duplicateLater); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; - expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]); + expect(draft?.attachments.map((image) => image.id)).toEqual(["img-a"]); expect(revokeSpy).toHaveBeenCalledWith("blob:b"); }); @@ -149,10 +149,10 @@ describe("composerDraftStore addImages", () => { previewUrl: "blob:shared", }); - useComposerDraftStore.getState().addImages(threadId, [first, duplicateSameUrl]); + useComposerDraftStore.getState().addAttachments(threadId, [first, duplicateSameUrl]); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; - expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]); + expect(draft?.attachments.map((image) => image.id)).toEqual(["img-shared"]); expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared"); }); }); @@ -178,7 +178,7 @@ describe("composerDraftStore clearComposerContent", () => { id: "img-optimistic", previewUrl: "blob:optimistic", }); - useComposerDraftStore.getState().addImage(threadId, first); + useComposerDraftStore.getState().addAttachment(threadId, first); useComposerDraftStore.getState().clearComposerContent(threadId); @@ -209,7 +209,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { id: "img-persisted", previewUrl: "blob:persisted", }); - useComposerDraftStore.getState().addImage(threadId, image); + useComposerDraftStore.getState().addAttachment(threadId, image); setLocalStorageItem( COMPOSER_DRAFT_STORAGE_KEY, { @@ -227,6 +227,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { useComposerDraftStore.getState().syncPersistedAttachments(threadId, [ { + type: "image", id: image.id, name: image.name, mimeType: image.mimeType, @@ -240,7 +241,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments, ).toEqual([]); expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.nonPersistedImageIds, + useComposerDraftStore.getState().draftsByThreadId[threadId]?.nonPersistedAttachmentIds, ).toEqual([image.id]); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 28a653296..7a902793c 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -962,35 +962,36 @@ function hydratePersistedComposerAttachmentFile( function hydrateAttachmentsFromPersisted( attachments: ReadonlyArray, ): ComposerAttachment[] { - return attachments.flatMap((attachment) => { + const hydrated: ComposerAttachment[] = []; + for (const attachment of attachments) { const file = hydratePersistedComposerAttachmentFile(attachment); - if (!file) return []; - - if (attachment.type === "image") { - return [ - { - type: "image" as const, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - previewUrl: attachment.dataUrl, - file, - } satisfies ComposerImageAttachment, - ]; + if (!file) { + continue; } - return [ - { - type: "file" as const, + if (attachment.type === "image") { + hydrated.push({ + type: "image" as const, id: attachment.id, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, + previewUrl: attachment.dataUrl, file, - } satisfies ComposerFileAttachment, - ]; - }); + } satisfies ComposerImageAttachment); + continue; + } + + hydrated.push({ + type: "file" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + file, + } satisfies ComposerFileAttachment); + } + return hydrated; } function toHydratedThreadDraft(