diff --git a/WORKLOG.md b/WORKLOG.md
index 6b5ad87..5a9a9e1 100644
--- a/WORKLOG.md
+++ b/WORKLOG.md
@@ -159,14 +159,18 @@ Details: Cycle 34 (Codex-6) applied `@fastify/rate-limit` to `POST /v1/channels/
Files: `services/core/src/app.ts` + `packages/contracts/src/protocol.ts` (flag Claude for the schema shape) + test
Details: Two related gaps Codex-12 surfaced: no `GET /v1/communities/:communityId/members` route exists (only mutation routes), and message `author` snapshots never refresh after send time (affects `customStatus` and presence `status` alike). Scope this as: add the member-list route (returns each member's current `Account` + role IDs, live-looked-up — this alone unblocks member-list-based visibility, which is a smaller, more contained fix than making message-author snapshots live). Flag to Claude whether refreshing message-author snapshots is worth pursuing separately, or deferred indefinitely as a "point-in-time" design choice.
+**Codex-18 landed** (`d7cc6ef`) — Claude did this one directly rather than assigning it (small, well-understood, faster to just do). Bumped to 50 MB. Found a deeper duplication while doing it: `initiateUploadRequestSchema`'s `size` field had its own independent `.max(26_214_400)` in `protocol.ts`, hardcoded separately from `app.ts`'s constant — bumping only the `app.ts` side would have left uploads silently capped at the old 25 MB via schema rejection regardless. Moved `MAX_ATTACHMENT_BYTES` into `@cove/contracts` as the single source of truth for both sides; both hardcoded `"25 MB"` error strings now derive from it. 2 new tests.
+
> **Note on the gap here:** Codex-13/14/15 (guided reports, community rename, rich link embeds) were reassigned off this queue and completed tonight (2026-07-02 evening) via a parallel Grok CLI trial run instead — see the "Grok" section below. DM channels (P3 item 32) is the natural next big backend feature, but it needs a real schema design session (`communityId` is required on every existing channel/message code path today; making DMs fit will touch a lot of call sites) rather than a rushed decision — flagging for a dedicated Claude scoping pass before assigning it to any agent.
---
-### Grok — ad-hoc parallel trial, 2026-07-02 evening (all 4 tasks landed)
+### Grok — ad-hoc parallel trial, 2026-07-02 evening (all 4 tasks landed) + AG-20 running
Operator has a 7-day trial of the Grok CLI (`C:\Users\Vornecs\.grok\bin\grok.exe`, headless via `-p`/`--prompt-file`). Workflow: each task ran in its own fully disposable local git clone (no `origin` remote, no `git alternates` — zero path back to the real repo) under the session scratchpad, with `--permission-mode bypassPermissions` scoped to that one throwaway directory (explicitly operator-authorized each time). Claude reviewed every diff, independently re-ran `typecheck`/`test` from scratch (not trusting Grok's self-report), fixed real gaps found, and manually re-applied each change into the real repo before committing — Grok never had direct write access to the real working tree or push access to GitHub.
+**Model correction:** the CLI's default resolved to `grok-composer-2.5-fast` at some point during tonight's earlier runs — its own model registry (`~/.grok/models_cache.json`) labels this model `"description": "Cursor's latest coding model"`, `"agent_type": "cursor"`, i.e. **not an xAI/Grok model at all**, apparently proxied through the same CLI. `~/.grok/config.toml` has `[models] default = "grok-build"` (labelled `"xAI's latest coding model"` in the same registry), which is what should be used — flagged by the operator, verified by checking `--debug-file` output confirming `model: "grok-build"` against `cli-chat-proxy.grok.com`. **All invocations from AG-20 onward pass `-m grok-build` explicitly** rather than relying on whatever resolves as default. Unclear whether the earlier 4 trial tasks tonight ran on `grok-build` or `grok-composer-2.5-fast` — the outputs were reviewed and verified independently either way, so the shipped code is sound regardless of which model produced it, but the model-identity itself for that batch is not confirmed.
+
**All 4 trial tasks landed and verified tonight:**
1. **Rich link-embed backend** (former Codex-15 scope, P3 item 16) — `4788fd8`. One real gap found and fixed by Claude: IPv4-mapped IPv6 addresses (e.g. `::ffff:10.0.0.5`) weren't checked against the private-IP blocklist, a genuine SSRF-adjacent gap.
@@ -174,6 +178,10 @@ Operator has a 7-day trial of the Grok CLI (`C:\Users\Vornecs\.grok\bin\grok.exe
3. **Community rename/edit endpoint** (former Codex-14 scope, P3 item 25, partial) — `f2788de`. Correctly caught a gap Claude's own task spec missed: `'community'` needed adding to `auditEventSchema.targetType`, not just the `action` enum, or the audit-log assertion would fail schema validation.
4. **Themes & appearance settings panel** (P2 item 15) — `5acd95f`. Matched spec closely; curated 5-preset palette, live CSS-variable preview, full accessibility attributes (`aria-pressed`/`aria-checked`/`radiogroup`), no `App.tsx` edit.
+**Landed, confirmed on `grok-build`:**
+
+5. **AG-20 — composer attachment upload UI** (P3 item 17, frontend half 1/2) — `b558f7a`. Extends `Composer.tsx` with drag-drop, file picker wiring, client-side image compression (canvas resize + re-encode, only above ~2MB), and a pending-attachment preview strip. Matched spec closely — no revision needed. Took noticeably longer than the earlier 4 tasks (60+ tool-call turns, multi-million cumulative tokens per the debug log) — almost certainly the jsdom canvas/Image mocking, which the task prompt had flagged as the tricky part up front. Worth knowing for future task sizing: browser-API-mocking-heavy frontend tasks cost meaningfully more than backend-route tasks of similar prompt complexity.
+
**Process note — real, not hypothetical:** tasks 2-4 ran while Codex was _simultaneously_ live-editing the same real `services/core/src/app.ts`/`app.test.ts` for Codex-12, uncommitted, in the same working tree. Verified this was really happening (via `tasklist`/hash-stability polling, not assumption) before touching anything. Landing tasks 2-3 required manually separating Claude's clean hunks from Codex's in-progress hunks via `git apply --cached` (hunk-by-hunk, since one import line was genuinely shared) rather than a blind `git add -A`, verifying the isolated staged state independently (via `git stash push --keep-index`) before committing, then restoring Codex's stash — one trivial one-line merge conflict on the shared import, resolved by keeping both. **Lesson for future concurrent-agent nights:** confirm file stability before any hunk-separation work, and don't trust a `npm test` run's failure at face value in a shared working tree without confirming which agent's code the failure actually belongs to (the first failing-test read this session belonged to a test Codex had already deleted by the time it was diagnosed).
---
@@ -196,7 +204,13 @@ Details: Per-channel notification settings beyond mute: all-messages / @mention-
**AG-19:** Attachment approval panel component (P3 item 27)
Files: `apps/web/src/AttachmentApprovalPanel.tsx` (new), unit tests
-Details: Admin approve/reject UI badge/panel for uploads stuck in "pending" quarantine. Backend quarantine route already exists (check `services/core/src/app.ts` for the current attachment-status route names before wiring props). Props: `attachment: { id, filename, status, uploadedBy }`, `onApprove: (id) => void`, `onReject: (id) => void`. Do NOT edit App.tsx.
+Details: **Correction from earlier assumption:** there is no existing approve/reject route to wire against — `app.ts`'s upload endpoint (`app.ts:2005`) auto-approves on successful upload (`quarantineStatus` goes straight from `'pending'` to `'approved'`), so nothing is actually stuck pending today. Build the panel UI on spec anyway (approve/reject buttons, badge states) since a real moderation-review step is a reasonable future addition, but note in your status file that a backend `PATCH`-style approve/reject route doesn't exist yet and would need to be added before this panel has anything real to call. Props: `attachment: { id, filename, status, uploadedBy }`, `onApprove: (id) => void`, `onReject: (id) => void`. Do NOT edit App.tsx.
+
+**AG-20 landed via Grok tonight** (`b558f7a`) — reviewed and matches spec closely: file picker + drag-drop wired to the previously-inert attach button, compression correctly gated to `image/*` over 2MB only, picks the smaller of JPEG/WebP re-encodes, graceful fallback to the original file on any error, proper object-URL cleanup via `useEffect`, presentation-only (no network calls). 6 new tests with solid `canvas`/`Image`/`URL` mocking. No revision needed.
+
+**AG-21:** Inline attachment rendering in messages (P3 item 17, frontend half 2/2) — **left queued for Antigravity's normal rotation**
+Files: `apps/web/src/MessageAttachment.tsx` (new), unit tests
+Details: `Message.attachments` (`Attachment[]` — `id`, `filename`, `mimeType`, `size`, `quarantineStatus`, `createdAt`) is already on every message from the backend; content is served from `GET /v1/attachments/:attachmentId/content`. Build a component that, given `attachments: Attachment[]`, renders each one inline: `image/*` MIME types render as a clickable `` (pointed at the content endpoint) with a sensible max-width/height so a large image doesn't blow out the message bubble — clicking opens the full image in a new tab is fine for v1, no lightbox needed. Everything else (video/audio/pdf/zip/text) renders as a compact download chip: filename, human-readable size, download icon, linking to the content endpoint. Props: `attachments: Attachment[]`. Full unit test coverage. Do NOT edit App.tsx or MessageList.tsx — Claude wires this into `MessageList.tsx`'s per-message render once both this and AG-20 land.
---
@@ -251,7 +265,7 @@ Claude pulls from here when agent work is integrated. Agents work from their own
### P3 — Core messaging polish
16. **Rich link embeds** — YouTube, Twitter/X, images, OG tags unfurl inline. ✅ backend unfurl endpoint (`POST /v1/link-preview`) shipped 2026-07-02 evening via Grok trial run + Claude review (`4788fd8`); frontend rendering (inline unfurl in message list) not yet assigned.
-17. **File & image uploads** — drag-drop, inline previews
+17. **File & image uploads** — drag-drop, inline previews. Operator explicitly asked for this (2026-07-02 evening) — wants pics uploadable in channels/servers/group chats, with client-side compression and limits looser than Discord's. Backend already fully built and working (initiate/upload/serve/attach-to-message) — this is a frontend-only gap. Split three ways per operator request: ✅ byte-cap bump done directly by Claude (Codex-18, `d7cc6ef`); ✅ composer upload UI done via Grok (AG-20, `b558f7a`); inline message rendering left queued for Antigravity's normal rotation (AG-21). **Unlike the rest of the unwired-component backlog, Claude is committing to wire these into `App.tsx` as soon as they land — not left to pile up.**
18. **Pinned messages** — backend done Cycle 34 (Codex-8: pin/unpin/list endpoints, permission-gated); UI (pin button + panel) — Claude Cycle 35 queue.
19. **Message search** — keyword search within channel (deferred — see P5, overlaps with the FTS stub)
20. ✅ ~~React error boundary~~ — component built Cycle 32 (AG-2), wired into `main.tsx` Cycle 33
diff --git a/apps/web/src/Composer.test.tsx b/apps/web/src/Composer.test.tsx
index d1c13df..bce3b27 100644
--- a/apps/web/src/Composer.test.tsx
+++ b/apps/web/src/Composer.test.tsx
@@ -1,7 +1,7 @@
import React from 'react';
-import { render, screen } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { describe, expect, it, vi } from 'vitest';
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import Composer from './Composer';
describe('Composer', () => {
@@ -163,3 +163,357 @@ describe('Composer', () => {
expect(sendBtn).toBeDisabled();
});
});
+
+// ---------------------------------------------------------------------------
+// Attachment UI tests (AG-20)
+// ---------------------------------------------------------------------------
+
+// Helpers to mock browser APIs that jsdom lacks full support for (canvas + Image onload)
+function setupBrowserApiMocks() {
+ // URL.create/revoke for previews and internal load
+ const createObjectURL = vi.fn(
+ (blob?: Blob) => `blob:mock-${(blob as any)?.size || Math.random()}`,
+ );
+ const revokeObjectURL = vi.fn();
+ // stub globals
+ vi.stubGlobal('URL', {
+ ...globalThis.URL,
+ createObjectURL,
+ revokeObjectURL,
+ });
+
+ // Mock Image constructor: trigger onload "immediately" (async microtask) when .src assigned
+ const MockImage = vi.fn(function (this: any) {
+ this.width = 4000;
+ this.height = 3000;
+ this.onload = null as (() => void) | null;
+ this.onerror = null as (() => void) | null;
+ let _src = '';
+ Object.defineProperty(this, 'src', {
+ configurable: true,
+ set(val: string) {
+ _src = val;
+ // fire async to mimic real load without hanging tests
+ Promise.resolve().then(() => {
+ if (this.onload) this.onload();
+ });
+ },
+ get() {
+ return _src;
+ },
+ });
+ });
+ vi.stubGlobal('Image', MockImage);
+
+ // Canvas 2d + toBlob: produce a deterministic smaller result; support jpeg/webp pick
+ const origGetContext = HTMLCanvasElement.prototype.getContext;
+ const origToBlob = HTMLCanvasElement.prototype.toBlob;
+
+ const mockCtx = { drawImage: vi.fn() };
+ HTMLCanvasElement.prototype.getContext = vi.fn(function (this: any, type: string) {
+ if (type === '2d') return mockCtx as any;
+ return null;
+ }) as any;
+
+ HTMLCanvasElement.prototype.toBlob = vi.fn(function (
+ this: any,
+ cb: (b: Blob | null) => void,
+ type = 'image/jpeg',
+ _q?: number,
+ ) {
+ const isWebp = (type || '').includes('webp');
+ const size = isWebp ? 42000 : 58000; // webp smaller so pick wins
+ const blob = new Blob([new Uint8Array(size)], { type: type || 'image/jpeg' });
+ // call sync (our impl awaits Promise wrapper anyway)
+ cb(blob);
+ }) as any;
+
+ return {
+ createObjectURL,
+ revokeObjectURL,
+ restore: () => {
+ try {
+ HTMLCanvasElement.prototype.getContext = origGetContext;
+ HTMLCanvasElement.prototype.toBlob = origToBlob;
+ } catch {}
+ vi.unstubAllGlobals();
+ },
+ };
+}
+
+describe('Composer attachments', () => {
+ let mocks: ReturnType;
+
+ beforeEach(() => {
+ mocks = setupBrowserApiMocks();
+ });
+
+ afterEach(() => {
+ mocks?.restore?.();
+ vi.restoreAllMocks();
+ });
+
+ it('clicking the attach button opens the file input (via ref.click)', async () => {
+ const user = userEvent.setup();
+ render(
+ {}}
+ onSend={() => {}}
+ replyTo={null}
+ onCancelReply={() => {}}
+ />,
+ );
+
+ const attachBtn = screen.getByRole('button', { name: 'Add attachment' });
+ const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click');
+
+ await user.click(attachBtn);
+
+ expect(clickSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('selecting files via the picker calls onFilesSelected with (possibly compressed) files', async () => {
+ const user = userEvent.setup();
+ const onFilesSelected = vi.fn();
+ render(
+ {}}
+ onSend={() => {}}
+ replyTo={null}
+ onCancelReply={() => {}}
+ onFilesSelected={onFilesSelected}
+ />,
+ );
+
+ const attachBtn = screen.getByRole('button', { name: 'Add attachment' });
+ await user.click(attachBtn);
+
+ const fileInput = screen.getByTestId('file-input') as HTMLInputElement;
+ const smallImg = new File([new Uint8Array(1234)], 'small.png', { type: 'image/png' });
+ await user.upload(fileInput, smallImg);
+
+ await waitFor(() => {
+ expect(onFilesSelected).toHaveBeenCalledTimes(1);
+ });
+ const calls = onFilesSelected.mock.calls;
+ const passed = (calls[0]?.[0] ?? []) as File[];
+ expect(passed).toHaveLength(1);
+ expect(passed[0]!.name).toBe('small.png');
+ expect(passed[0]!.type).toBe('image/png');
+ });
+
+ it('dropping files onto the composer calls onFilesSelected', async () => {
+ const onFilesSelected = vi.fn();
+ render(
+ {}}
+ onSend={() => {}}
+ replyTo={null}
+ onCancelReply={() => {}}
+ onFilesSelected={onFilesSelected}
+ />,
+ );
+
+ const form = screen.getByTestId('composer-form');
+ const txtFile = new File(['hello world'], 'note.txt', { type: 'text/plain' });
+
+ const dataTransfer = {
+ files: [txtFile],
+ items: [{ kind: 'file', getAsFile: () => txtFile }],
+ types: ['Files'],
+ } as any;
+
+ fireEvent.dragEnter(form, { dataTransfer });
+ fireEvent.dragOver(form, { dataTransfer });
+ fireEvent.drop(form, { dataTransfer });
+
+ await waitFor(() => {
+ expect(onFilesSelected).toHaveBeenCalledTimes(1);
+ });
+ const calls = onFilesSelected.mock.calls;
+ const passed = (calls[0]?.[0] ?? []) as File[];
+ expect(passed[0]!.name).toBe('note.txt');
+ });
+
+ it('renders pending-attachment strip with one chip per item; thumbnails for images, filename for others', () => {
+ const imgFile = new File([new Uint8Array(100)], 'photo.jpg', { type: 'image/jpeg' });
+ const vidFile = new File([new Uint8Array(200)], 'clip.mp4', { type: 'video/mp4' });
+
+ const pending = [
+ { file: imgFile, previewUrl: 'blob:mock-preview-img' },
+ { file: vidFile }, // no preview -> filename only
+ ];
+
+ render(
+ {}}
+ onSend={() => {}}
+ replyTo={null}
+ onCancelReply={() => {}}
+ pendingAttachments={pending}
+ />,
+ );
+
+ expect(screen.getByTestId('attachment-strip')).toBeInTheDocument();
+ expect(screen.getByTestId('attachment-chip-0')).toBeInTheDocument();
+ expect(screen.getByTestId('attachment-chip-1')).toBeInTheDocument();
+
+ // image chip should have
+ const chip0 = screen.getByTestId('attachment-chip-0');
+ expect(chip0.querySelector('img.attachment-thumb')).toBeInTheDocument();
+ expect(chip0.querySelector('img')!.getAttribute('src')).toBe('blob:mock-preview-img');
+
+ // non-image shows filename text only (no img)
+ const chip1 = screen.getByTestId('attachment-chip-1');
+ expect(chip1.querySelector('img')).not.toBeInTheDocument();
+ expect(chip1.textContent).toContain('clip.mp4');
+ });
+
+ it('clicking a remove button calls onRemoveAttachment with the correct index', async () => {
+ const user = userEvent.setup();
+ const onRemove = vi.fn();
+ const f1 = new File([new Uint8Array(10)], 'a.png', { type: 'image/png' });
+ const f2 = new File([new Uint8Array(20)], 'b.pdf', { type: 'application/pdf' });
+
+ render(
+ {}}
+ onSend={() => {}}
+ replyTo={null}
+ onCancelReply={() => {}}
+ pendingAttachments={[{ file: f1, previewUrl: 'blob:1' }, { file: f2 }]}
+ onRemoveAttachment={onRemove}
+ />,
+ );
+
+ const chip0 = screen.getByTestId('attachment-chip-0');
+ const remove0 = chip0.querySelector('button')!;
+ await user.click(remove0);
+
+ expect(onRemove).toHaveBeenCalledTimes(1);
+ expect(onRemove).toHaveBeenCalledWith(0);
+
+ const chip1 = screen.getByTestId('attachment-chip-1');
+ const remove1 = chip1.querySelector('button')!;
+ await user.click(remove1);
+
+ expect(onRemove).toHaveBeenCalledTimes(2);
+ expect(onRemove).toHaveBeenLastCalledWith(1);
+ });
+
+ it('large image goes through compression (size reduced via canvas path); small image and non-image do not', async () => {
+ const user = userEvent.setup();
+
+ // large
+ const onSelLarge = vi.fn();
+ const getCtxSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getContext');
+
+ const r1 = render(
+ {}}
+ onSend={() => {}}
+ replyTo={null}
+ onCancelReply={() => {}}
+ onFilesSelected={onSelLarge}
+ />,
+ );
+
+ const big = new File([new Uint8Array(3_200_000)], 'huge.png', { type: 'image/png' });
+ const input = screen.getByTestId('file-input') as HTMLInputElement;
+ await user.upload(input, big);
+
+ await waitFor(() => expect(onSelLarge).toHaveBeenCalled());
+ const callsL = onSelLarge.mock.calls;
+ const resLarge = (callsL[0]?.[0]?.[0] ?? {}) as File;
+ expect(getCtxSpy).toHaveBeenCalled(); // compression path exercised
+ expect(resLarge.size).toBeLessThan(big.size);
+ expect(resLarge.size).toBe(42000); // the webp one we mocked as smaller
+ r1.unmount();
+
+ // fresh render for small: size unchanged proves no compression path
+ const onSelSmall = vi.fn();
+ const r2 = render(
+ {}}
+ onSend={() => {}}
+ replyTo={null}
+ onCancelReply={() => {}}
+ onFilesSelected={onSelSmall}
+ />,
+ );
+ const small = new File([new Uint8Array(900_000)], 'tiny.jpg', { type: 'image/jpeg' });
+ const inp2 = screen.getByTestId('file-input') as HTMLInputElement;
+ await user.upload(inp2, small);
+ await waitFor(() => expect(onSelSmall).toHaveBeenCalled());
+ const callsS = onSelSmall.mock.calls;
+ const resSmall = (callsS[0]?.[0]?.[0] ?? {}) as File;
+ expect(resSmall.size).toBe(small.size);
+ r2.unmount();
+
+ // non-image (even if "large"): size unchanged proves untouched
+ const onSelNon = vi.fn();
+ const r3 = render(
+ {}}
+ onSend={() => {}}
+ replyTo={null}
+ onCancelReply={() => {}}
+ onFilesSelected={onSelNon}
+ />,
+ );
+ const pdf = new File([new Uint8Array(4_000_000)], 'bigdoc.pdf', { type: 'application/pdf' });
+ const inp3 = screen.getByTestId('file-input') as HTMLInputElement;
+ await user.upload(inp3, pdf);
+ await waitFor(() => expect(onSelNon).toHaveBeenCalled());
+ const callsN = onSelNon.mock.calls;
+ const resPdf = (callsN[0]?.[0]?.[0] ?? {}) as File;
+ expect(resPdf.size).toBe(pdf.size);
+ r3.unmount();
+ });
+
+ it('keeps the original file when every re-encoded candidate is larger than the source', async () => {
+ // Override toBlob for this test only: produce blobs bigger than the
+ // source file, so compression must fall back to the original rather
+ // than "compressing" into something larger.
+ HTMLCanvasElement.prototype.toBlob = vi.fn(function (
+ this: HTMLCanvasElement,
+ cb: (b: Blob | null) => void,
+ type = 'image/jpeg',
+ ) {
+ const blob = new Blob([new Uint8Array(5_000_000)], { type: type || 'image/jpeg' });
+ cb(blob);
+ }) as typeof HTMLCanvasElement.prototype.toBlob;
+
+ const user = userEvent.setup();
+ const onSelected = vi.fn();
+ render(
+ {}}
+ onSend={() => {}}
+ replyTo={null}
+ onCancelReply={() => {}}
+ onFilesSelected={onSelected}
+ />,
+ );
+
+ const big = new File([new Uint8Array(2_500_000)], 'oversized-reencode.png', {
+ type: 'image/png',
+ });
+ const input = screen.getByTestId('file-input') as HTMLInputElement;
+ await user.upload(input, big);
+
+ await waitFor(() => expect(onSelected).toHaveBeenCalled());
+ const passed = (onSelected.mock.calls[0]?.[0] ?? []) as File[];
+ expect(passed[0]!.size).toBe(big.size);
+ expect(passed[0]!.type).toBe('image/png');
+ });
+});
diff --git a/apps/web/src/Composer.tsx b/apps/web/src/Composer.tsx
index 5a9f3a2..6a88bf7 100644
--- a/apps/web/src/Composer.tsx
+++ b/apps/web/src/Composer.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useRef, useState, useEffect } from 'react';
import { IconButton } from '@cove/ui';
import { LockKeyhole, Plus, Smile, SendHorizontal } from 'lucide-react';
@@ -11,6 +11,10 @@ export interface ComposerProps {
disabled?: boolean;
placeholder?: string;
isSealed?: boolean;
+ // Attachment UI (controlled by parent; Composer does not perform network)
+ pendingAttachments?: Array<{ file: File; previewUrl?: string }>;
+ onFilesSelected?: (files: File[]) => void;
+ onRemoveAttachment?: (index: number) => void;
}
export function Composer({
@@ -22,7 +26,58 @@ export function Composer({
disabled = false,
placeholder = 'Message...',
isSealed = false,
+ pendingAttachments = [],
+ onFilesSelected = () => {},
+ onRemoveAttachment = () => {},
}: ComposerProps) {
+ const fileInputRef = useRef(null);
+ const [isDragging, setIsDragging] = useState(false);
+ // Track object URLs we created for previews (when parent does not supply previewUrl)
+ const generatedUrlsRef = useRef