feat(mobile): per-message file attachments in the chat composer (#12804)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
SubashMohanandClaude Opus 4.8 authored and GitHub committed 2026-07-09 15:33:46 +00:00
1 parent 9cb8c3475e
commit bc0e90b1aa
22 files changed
+1384 -140

No files matched your search

+16
View File
@@ -209,6 +209,22 @@ After PR 6, the forked `ChatConversation` + `ProjectView` were collapsed into on
- **Tests on merge:** RN Testing Library — attach→chip→send-gating blocks until indexed; `file_descriptors[]` built correctly; image preview renders.
- **Before you start (grill on):** Send-gating UX while indexing + error surfacing for `FAILED`. `file_descriptors` field mapping (`file_id`→`id`, `chat_file_type`→`type`, `id`→`user_file_id`). Image-preview auth (`GET /api/chat/file/{id}` needs bearer in `<Image>` — header vs signed URL). Reuse vs minor duplication with PR 7's uploader.
- **Drift checkpoint:** This completes the locked scope — confirm before starting whether camera moves in-scope.
- **As built (2026-07-07, "full web parity" — grilled before coding):** Owner chose the full web-faithful path on all four gates (`AskUserQuestion` ×4): **(1) send-gating** = hard-disable Send until every attachment reaches a terminal status; a small status line under the chips; a FAILED file shows an error + stays removable (removal unblocks send). **(2) image thumbnails NOW** via bearer `expo-image` (`GET /chat/file/{file_id}`). **(3) pickers** = documents + photos + recent-files (reuses `FilePickerSheet`); **no camera**. **(4) composer** = full three-row restyle **incl. multiline auto-grow**. **The upload "blocker" was closed by evidence, not grilled:** per-message attachments POST to the **same** `/user/projects/file/upload` with **`project_id` omitted** — backend already declares `project_id: int | None = Form(None)` (`backend/onyx/server/features/projects/api.py:132`) and web does exactly this (`beginUpload(files, null)`). **PR 8 is backend-free.**
- **Reuse map (verified):** the PR 7 uploader generalizes — `uploadProjectFile` → **`uploadUserFile(asset, projectId: number|null, …)`** (omits the `project_id` Form param when null; the mobile analog of web's `beginUpload(files, projectId?)`); pickers (`pickDocuments`/`pickImages`), `FilePickerSheet`, and `getUserFileStatuses` reused as-is; the optimistic-file builder + status-label + `isFailedFile` extracted to shared helpers in `lib/files.ts` (`buildOptimisticFile`/`attachmentStatusLabel`/`isFailedFile`), consumed by both the project and message flows. `useProjectFiles` was **not** generalized — a leaner sibling `useMessageAttachments` was written because the project hook is coupled to the project **query cache** (invalidate/patch `userProject`) + link/unlink, none of which apply to a per-message draft. (Web co-locates both in one `ProjectsContext`; mobile never ported that context, so a focused hook is the mobile-native equivalent of web's `currentMessageFiles` slice.)
- **One `FileCard` for every surface (web-faithful, post-grill decision).** Rather than a separate composer chip component, mobile's **single `FileCard`** renders image files as a square thumbnail (bearer `expo-image`) and everything else — plus any failed upload — as a bordered pill, and is used by the composer strip, a sent message's attachments, and the project panel (mirrors web, where one `FileCard` serves `AppInputBar` + `ProjectContextPanel` + the agent viewer). Each surface just wraps `FileCard`s in its own layout (`flex-wrap` strip vs the panel list). Remove is gated on `!uploading` uniformly (web's `doneUploading`).
- **New files:** `chat/fileDescriptors.ts` (pure `projectFilesToFileDescriptors` — `file_id→id`, `chat_file_type→type`, `id→user_file_id`, mirroring web's `projectsFileToFileDescriptor`; + the inverse `fileDescriptorToDisplayFile` for rendering a sent message's files); `hooks/useMessageAttachments.ts` (local-state draft: pick → `uploadUserFile(…, null, …)` → reconcile-by-`temp_id` → 3s `/statuses` poll → `descriptors`/`hasBlockingFiles`/`clear`, **self-resets on a `resetKey` = `${sessionId}:${projectId}`**); `components/chat/AttachmentImage.tsx` (bearer `expo-image`, mirrors `AgentImage`); `icons/paperclip.tsx` (faithful port of web's SVG).
- **Changed:** `FileCard.tsx` became the unified image-thumbnail-or-pill card; `InputBar.tsx` restyled to the web composer shape (rounded container → `FileCard` strip → auto-grow multiline `TextInput` → control row: paperclip-left opening `FilePickerSheet` + send/stop-right; blocking status line + inline error banner); `useChatController.submit(overrideMessage?, files?)` threads real descriptors into **both** `buildImmediateMessages` (optimistic user node) and the send body's `file_descriptors`; `ChatSurface` mounts `useMessageAttachments`, and `onSend` captures `descriptors` before `clear()` so the clear can't race the async send; `MessageRow` renders a sent user message's `files` read-only; `ProjectContextPanel` renders the same `FileCard` in a `flex-wrap` strip.
- **Async-safety design:** the composer is one persistent instance, so all upload/poll writes capture the `resetKey` (a `keyRef`) and patch optimistic entries **by `temp_id` via functional `setState`** — a late upload/poll for a conversation the user already left is dropped, never resurrected. Verified by test (switch conversations mid-upload → the reconcile is dropped).
- **Divergences from web (documented):** (1) **FAILED blocks send** until removed (web allows send with a failed file) — owner-chosen, cleaner UX. (2) **Enter = newline**, send via button (web Enter = send) — mobile composer idiom. (3) recent-file "attach" is purely client-side (no server link, matching web's `onPickRecent`). (4) errors are an inline banner (no toast primitive on mobile). (5) no camera; no deep-research/actions/voice toolbar controls (deferred). (6) `expo-image` `cachePolicy="none"` (bearer, per `AgentImage`). *(The earlier "dedicated AttachmentChips" divergence was removed — mobile now uses one `FileCard` like web.)*
- **Verified:** mobile `tsc` clean, lint clean (2 pre-existing `_layout` warnings), **250 jest pass** (`fileDescriptors` mapping/inverse · `useMessageAttachments` upload/reconcile/size-precheck/reject/recent/clear/resetKey-reset/late-write-guard/poll · `FileCard` image-vs-doc/failed/remove-gating/read-only · `useChatController` file_descriptors threading · `uploadUserFile` project-less path). **Adversarial multi-agent review** = 4 dimensions × skeptic verification, **10 findings → 8 refuted → 2 confirmed** (both code-quality, applied: removed a dead `isUploading`; extracted the duplicated `isFailedFile`) — **0 correctness/race defects survived**, validating the ownership-key guards. Real bugs caught in self-review and fixed: a doc card stuck INDEXING/FAILED was un-removable (would have wedged send with no escape) and a duplicated `optimisticFile` (now the shared `buildOptimisticFile`). **HARD GATE STILL OWED:** native rebuild (`expo prebuild --clean` + `run:ios`/`run:android`) to verify pick→upload→thumbnail→send-gating→send on device, multiline auto-grow keyboard behavior, and the bearer image thumbnails (the agent can't build a device app).
## Post-PR 8 refactor — `rework-userfiles-hooks` (planned; owner-driven)
- **Why:** decouple user-file handling from sessions/projects. Today PR 7's `useProjectFiles` is **project-keyed** and PR 8's `useMessageAttachments` is a **per-conversation local draft** — two forked, ephemeral file layers. Target (owner, 2026-07-09): a single **file-keyed** source of truth (generalize `uploadStore` from `byProject` → `Map<fileId, entry>` + one shared status poller), with three hooks over it — a core `useUserFiles` (upload/delete/status) and two lean lenses `useMessageAttachments` (per-conversation draft = id refs) and `useProjectFiles` (link/unlink). Session/project become **links** applied on top, never the file's identity, so an upload survives navigation instead of being trapped in the conversation it started in. Mobile keeps the **store** pattern (not web's `ProjectsContext`): state must outlive the morphing `ChatSurface` and be written from detached async callbacks, and selectors avoid re-rendering every file consumer on each progress tick. Full LLD produced in that PR's grill session.
- **Known bugs to fix in this rework** — deferred from PR 8's GitHub-bot review ([PR #12804](https://github.com/onyx-dot-app/onyx/pull/12804); all P2, none shipped-broken today):
1. **Clear-on-failed-submit loses the draft.** `ChatSurface.sendWithAttachments` clears attachments right after `void submit(...)`; if send *setup* fails (e.g. new-session create), the draft is gone and unretryable. Fix needs `submit` to return an **accepted** signal so the clear only fires on success — natural once the file lives in the central store (it survives regardless of the send). (`components/chat/ChatSurface.tsx`.)
2. **`partitionBySize` treats a null/0 upload-size setting as "unlimited."** When `user_file_max_upload_size_mb` is missing/0/invalid the client precheck is skipped, so oversized files only fail server-side. Decide a **finite fallback** (owner call — a constant or a real server config) vs. keep deferring to the server. (`mobile/src/lib/files.ts`.)
3. **Async guards key on conversation, not a per-run epoch.** An upload that spans **leave → return to the same chat** is treated as live and can surface a **stale error banner** for a draft the user already cleared. A per-file/per-run identity token in the store makes late writes self-invalidating. (PR 8 fixed the *mid-pick* cross-chat leak and the picker-error guard; this same-conversation case remains.) (`hooks/useMessageAttachments.ts`.)
- **Cleanup rider (not a bug):** extract a shared `BearerImage` primitive so `AttachmentImage` + `AgentImage` stop duplicating the auth'd-image + `cachePolicy="none"` cache-safety invariant.
## PR 9a–9e — Deferred rich-chat (each its own phase)
- **Goal:** Enrich the working core, one independent feature at a time: **9a** citations/sources · **9b** agentic reasoning timeline (reasoning/search/tool sub-steps) · **9c** regenerate/edit/feedback · **9d** follow-up suggestions · **9e** image-generation rendering.
+26 -7
View File
@@ -5,7 +5,7 @@ import { File } from "expo-file-system";
import { getToken } from "@/api/auth/tokenStore";
import {
buildFileKey,
uploadProjectFile,
uploadUserFile,
type NormalizedAsset,
} from "@/api/files/upload";
@@ -61,7 +61,7 @@ describe("buildFileKey", () => {
});
});
describe("uploadProjectFile", () => {
describe("uploadUserFile", () => {
beforeEach(() => {
FileMock.mockReset();
getTokenMock.mockReset();
@@ -78,7 +78,7 @@ describe("uploadProjectFile", () => {
headers: {},
});
const result = await uploadProjectFile(asset, 5, "tmp-1");
const result = await uploadUserFile(asset, 5, "tmp-1");
expect(FileMock).toHaveBeenCalledWith("file:///tmp/a.pdf");
const call = upload.mock.calls[0] as [string, Record<string, unknown>];
@@ -107,7 +107,7 @@ describe("uploadProjectFile", () => {
headers: {},
});
await uploadProjectFile(asset, 5, "tmp-1");
await uploadUserFile(asset, 5, "tmp-1");
const options = (
upload.mock.calls[0] as [string, { headers?: unknown }]
@@ -115,25 +115,44 @@ describe("uploadProjectFile", () => {
expect(options.headers).toBeUndefined();
});
it("omits project_id for a per-message attachment (projectId null)", async () => {
getTokenMock.mockResolvedValue("tok");
const upload = mockUpload({
status: 200,
body: JSON.stringify({ user_files: [], rejected_files: [] }),
headers: {},
});
await uploadUserFile(asset, null, "tmp-1");
const options = (
upload.mock.calls[0] as [string, { parameters: Record<string, string> }]
)[1];
expect("project_id" in options.parameters).toBe(false);
expect(JSON.parse(options.parameters.temp_id_map)).toEqual({
"10|a.pdf": "tmp-1",
});
});
it("throws on a non-2xx upload (it resolves, so status is checked)", async () => {
getTokenMock.mockResolvedValue("tok");
mockUpload({ status: 500, body: "boom", headers: {} });
await expect(uploadProjectFile(asset, 5, "tmp-1")).rejects.toThrow();
await expect(uploadUserFile(asset, 5, "tmp-1")).rejects.toThrow();
});
it("throws when a 2xx body is valid JSON but the wrong shape", async () => {
getTokenMock.mockResolvedValue("tok");
mockUpload({ status: 200, body: JSON.stringify({}), headers: {} });
await expect(uploadProjectFile(asset, 5, "tmp-1")).rejects.toThrow();
await expect(uploadUserFile(asset, 5, "tmp-1")).rejects.toThrow();
});
it("throws an ApiError (not a raw SyntaxError) when a 2xx body isn't JSON", async () => {
getTokenMock.mockResolvedValue("tok");
mockUpload({ status: 200, body: "<html>nope</html>", headers: {} });
await expect(uploadProjectFile(asset, 5, "tmp-1")).rejects.toMatchObject({
await expect(uploadUserFile(asset, 5, "tmp-1")).rejects.toMatchObject({
name: "ApiError",
status: 200,
});
+10 -7
View File
@@ -30,25 +30,28 @@ export function buildFileKey(asset: NormalizedAsset): string {
// Native multipart uploader: streams from disk (no FormData memory spike) and
// reports byte progress. Bypasses apiFetch, so the bearer is attached manually;
// it resolves for non-2xx, so status is checked.
export async function uploadProjectFile(
// it resolves for non-2xx, so status is checked. `projectId` null → an unlinked
// user file (per-message attachment); the backend's project_id Form default is None.
export async function uploadUserFile(
asset: NormalizedAsset,
projectId: number,
projectId: number | null,
tempId: string,
onProgress?: (ratio: number) => void,
): Promise<CategorizedFiles> {
const token = await getToken();
const url = `${getBaseUrl()}/user/projects/file/upload`;
const parameters: Record<string, string> = {
temp_id_map: JSON.stringify({ [buildFileKey(asset)]: tempId }),
};
if (projectId != null) parameters.project_id = String(projectId);
const result = await new File(asset.uri).upload(url, {
httpMethod: "POST",
uploadType: UploadType.MULTIPART,
fieldName: "files",
mimeType: asset.mimeType,
parameters: {
project_id: String(projectId),
temp_id_map: JSON.stringify({ [buildFileKey(asset)]: tempId }),
},
parameters,
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
onProgress: ({ bytesSent, totalBytes }) => {
if (totalBytes > 0) onProgress?.(bytesSent / totalBytes);
+1 -1
View File
@@ -5,7 +5,7 @@ import { AppSidebar } from "@/components/chat/AppSidebar";
import { ChatSurface } from "@/components/chat/ChatSurface";
// ChatSurface overlays the <Stack> as one persistent surface so chat routes morph in place;
// the Stack drives URLs/back-stack (chat routes render null). See docs/mobile-chat/06-unified-chat-surface.md.
// the Stack drives URLs/back-stack (chat routes render null).
export default function AppLayout() {
return (
<View className="flex-1">
@@ -0,0 +1,68 @@
import { describe, expect, it } from "@jest/globals";
import {
fileDescriptorToDisplayFile,
projectFileToFileDescriptor,
projectFilesToFileDescriptors,
} from "@/chat/fileDescriptors";
import { UserFileStatus } from "@/chat/contracts/projects";
import { ChatFileType } from "@/chat/interfaces";
import { makeProjectFile } from "@/chat/__tests__/fixtures";
describe("projectFileToFileDescriptor", () => {
it("remaps the record keys to the send payload shape", () => {
const file = makeProjectFile({
id: "user-file-9",
file_id: "blob-abc",
name: "report.pdf",
chat_file_type: ChatFileType.DOCUMENT,
});
expect(projectFileToFileDescriptor(file)).toEqual({
id: "blob-abc", // file_id → id
type: ChatFileType.DOCUMENT, // chat_file_type → type
name: "report.pdf",
user_file_id: "user-file-9", // id → user_file_id
});
});
it("maps a list", () => {
const files = [
makeProjectFile({ id: "a", file_id: "fa" }),
makeProjectFile({ id: "b", file_id: "fb" }),
];
expect(projectFilesToFileDescriptors(files).map((d) => d.id)).toEqual([
"fa",
"fb",
]);
});
});
describe("fileDescriptorToDisplayFile", () => {
it("inverts the mapping and marks the file completed for display", () => {
const display = fileDescriptorToDisplayFile({
id: "blob-abc",
type: ChatFileType.IMAGE,
name: "pic.png",
user_file_id: "user-file-9",
});
expect(display).toMatchObject({
id: "user-file-9",
file_id: "blob-abc", // thumbnail is fetched from /chat/file/{file_id}
name: "pic.png",
chat_file_type: ChatFileType.IMAGE,
status: UserFileStatus.COMPLETED,
});
});
it("falls back for a null name / missing user_file_id", () => {
const display = fileDescriptorToDisplayFile({
id: "blob-x",
type: ChatFileType.DOCUMENT,
name: null,
});
expect(display.name).toBe("File");
expect(display.id).toBe("blob-x"); // user_file_id ?? id
});
});
+37
View File
@@ -0,0 +1,37 @@
// Mobile-native, per the PR 2 no-shared-chat-code decision (web keeps its own
// web/src/lib/projects/utils.ts). Converts an uploaded file record into the send
// payload shape: the keys differ — file_id → id, chat_file_type → type, id →
// user_file_id — matching web's projectsFileToFileDescriptor exactly.
import { UserFileStatus, type ProjectFile } from "@/chat/contracts/projects";
import type { FileDescriptor } from "@/chat/interfaces";
export function projectFileToFileDescriptor(file: ProjectFile): FileDescriptor {
return {
id: file.file_id,
type: file.chat_file_type,
name: file.name,
user_file_id: file.id,
};
}
export function projectFilesToFileDescriptors(
files: ProjectFile[],
): FileDescriptor[] {
return files.map(projectFileToFileDescriptor);
}
// Inverse: a sent message's descriptor → a display file record so the chip strip can
// render it read-only. A sent attachment is always indexed, hence COMPLETED.
export function fileDescriptorToDisplayFile(
descriptor: FileDescriptor,
): ProjectFile {
return {
id: descriptor.user_file_id ?? descriptor.id,
name: descriptor.name ?? "File",
file_id: descriptor.id,
status: UserFileStatus.COMPLETED,
chat_file_type: descriptor.type,
token_count: null,
created_at: "",
};
}
@@ -0,0 +1,54 @@
import { Image } from "expo-image";
import { useMemo } from "react";
import { View } from "react-native";
import { getBaseUrl } from "@/api/config";
import { useAuthToken } from "@/hooks/useAuthToken";
interface AttachmentImageProps {
fileId: string;
size: number;
radius?: number;
}
// Auth'd image preview (GET /chat/file/{file_id}) for an attachment thumbnail. Mirrors
// AgentImage: caching is disabled because the URL isn't keyed by auth, so a shared cache
// could serve one account's file to the next after a switch. Neutral placeholder until the
// bearer resolves.
export function AttachmentImage({
fileId,
size,
radius = 8,
}: AttachmentImageProps) {
const token = useAuthToken();
const dimension = useMemo(
() => ({ width: size, height: size, borderRadius: radius }),
[size, radius],
);
// Stable source ref (cachePolicy="none" means a fresh object would trigger a re-fetch).
// baseUrl is a dep so the source changes at an instance switch, not just on fileId/token.
const baseUrl = getBaseUrl();
const source = useMemo(
() =>
token
? {
uri: `${baseUrl}/chat/file/${fileId}`,
headers: { Authorization: `Bearer ${token}` },
}
: null,
[baseUrl, fileId, token],
);
if (!source) {
return <View style={dimension} className="bg-background-tint-01" />;
}
return (
<Image
source={source}
style={dimension}
contentFit="cover"
cachePolicy="none"
/>
);
}
+21 -7
View File
@@ -22,12 +22,13 @@ import { deriveFocus, type ChatFocus } from "@/chat/chatFocus";
import { useChatController } from "@/hooks/useChatController";
import { useChatSessionController } from "@/hooks/useChatSessionController";
import { useLiveAgent } from "@/hooks/useLiveAgent";
import { useMessageAttachments } from "@/hooks/useMessageAttachments";
const TRANSITION_MS = 150;
// Persistent chat surface: one absolute overlay mounted in (app)/_layout, morphed in place
// by route → focus so the header/composer never remount. null on non-chat routes (the Stack
// screen shows through). See docs/mobile-chat/06-unified-chat-surface.md.
// screen shows through).
export function ChatSurface() {
const pathname = usePathname();
const focus = deriveFocus(pathname);
@@ -75,6 +76,12 @@ function ChatSurfaceContent({ focus }: { focus: ChatFocus }) {
// re-attaches to an in-flight run when opened cold
useChatSessionController(sessionId);
// Per-message attachment draft. Self-resets on `[sessionId, projectId]` (see the hook)
// so it can't leak across the persistent composer.
const attachments = useMessageAttachments(
`${sessionId ?? "new"}:${projectId ?? ""}`,
);
// The composer is one persistent instance across every focus; clear any unsent draft
// when the target conversation changes so it can't be sent into the wrong session.
useEffect(() => {
@@ -93,16 +100,25 @@ function ChatSurfaceContent({ focus }: { focus: ChatFocus }) {
: UNNAMED_CHAT
: undefined;
// Capture descriptors before clearing; submit reads its `files` arg, not the live draft,
// so the clear can't race the send. Shared by the composer and the empty-state starters.
// Gate on hasBlockingFiles so the starter path can't send temp-id descriptors for a file
// that's still uploading/indexing/failed (the composer's send button is already disabled).
const sendWithAttachments = (message?: string) => {
if (attachments.hasBlockingFiles) return;
void submit(message, attachments.descriptors);
attachments.clear();
};
const composer = (
<Animated.View layout={LinearTransition.duration(TRANSITION_MS)}>
<InputBar
value={input}
onChangeText={setInput}
onSend={() => {
void submit();
}}
onSend={() => sendWithAttachments()}
onStop={stop}
chatState={chatState}
attachments={attachments}
/>
</Animated.View>
);
@@ -161,9 +177,7 @@ function ChatSurfaceContent({ focus }: { focus: ChatFocus }) {
<ChatEmptyState
agent={liveAgent}
isDefaultAgent={isDefaultAgent}
onStarterSelect={(message) => {
void submit(message);
}}
onStarterSelect={sendWithAttachments}
/>
</Animated.View>
) : (
+84 -40
View File
@@ -1,6 +1,7 @@
import { ActivityIndicator, View } from "react-native";
import { memo } from "react";
import { ActivityIndicator, Pressable, View } from "react-native";
import { Button } from "@/components/ui/button";
import { AttachmentImage } from "@/components/chat/AttachmentImage";
import { Icon } from "@/components/ui/icon";
import { Text } from "@/components/ui/text";
import {
@@ -8,71 +9,114 @@ import {
UserFileStatus,
type ProjectFile,
} from "@/chat/contracts/projects";
import { extensionOf } from "@/lib/files";
import { ChatFileType } from "@/chat/interfaces";
import { attachmentStatusLabel, isFailedFile, isImageName } from "@/lib/files";
import { cn } from "@/lib/utils";
import SvgAlertCircle from "@/icons/alert-circle";
import SvgFileText from "@/icons/file-text";
import SvgX from "@/icons/x";
const IMAGE_SIZE = 64;
interface FileCardProps {
file: ProjectFile;
onRemove?: () => void;
// Omit for a read-only card (a sent message's files).
onRemove?: (id: string) => void;
progress?: number; // 0..1 while UPLOADING
}
function statusLabel(file: ProjectFile, progress?: number): string {
switch (String(file.status).toUpperCase()) {
case UserFileStatus.UPLOADING:
return progress != null
? `Uploading… ${Math.round(progress * 100)}%`
: "Uploading…";
case UserFileStatus.PROCESSING:
return "Processing…";
case UserFileStatus.INDEXING:
return "Indexing…";
case UserFileStatus.DELETING:
return "Deleting…";
case UserFileStatus.FAILED:
return "Failed";
default:
// web leaves this blank; "File" avoids a lone filename
return extensionOf(file.name) || "File";
}
function isImage(file: ProjectFile): boolean {
return file.chat_file_type === ChatFileType.IMAGE || isImageName(file.name);
}
// Image thumbnails are deferred to PR 8 (auth-image bearer).
export function FileCard({ file, onRemove, progress }: FileCardProps) {
const processing = isProcessingStatus(file.status);
const deleting =
String(file.status).toUpperCase() === UserFileStatus.DELETING;
const canRemove = onRemove != null && !processing && !deleting;
// The single file-display card (mirrors web's FileCard), used by the composer strip, a
// sent message's attachments, and the project panel. Images render as a square thumbnail;
// everything else — and any failed upload — as a bordered pill. Removable once the upload
// lands (matches web); a file stuck INDEXING/FAILED stays removable so the user can unblock
// send.
// Memoized: it sits in the composer strip, which re-renders on every keystroke; its props
// (file identity, the stable removeFile callback, per-file progress) only change on real edits.
export const FileCard = memo(function FileCard({
file,
onRemove,
progress,
}: FileCardProps) {
const uploading =
String(file.status).toUpperCase() === UserFileStatus.UPLOADING;
const canRemove = onRemove != null && !uploading;
if (isImage(file) && !isFailedFile(file)) {
return (
<View className="relative" testID="file-image-card">
<View
className="items-center justify-center overflow-hidden rounded-08 border border-border-01"
style={{ width: IMAGE_SIZE, height: IMAGE_SIZE }}
>
{uploading ? (
<ActivityIndicator size="small" />
) : (
<AttachmentImage fileId={file.file_id} size={IMAGE_SIZE} />
)}
</View>
{canRemove ? (
<Pressable
onPress={() => onRemove(file.id)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Remove ${file.name}`}
style={{ top: -6, right: -6 }}
className="bg-background-inverted-05 absolute h-20 w-20 items-center justify-center rounded-full border border-border-01"
>
<Icon as={SvgX} size={12} className="text-text-inverted-05" />
</Pressable>
) : null}
</View>
);
}
const processing = isProcessingStatus(file.status);
const failed = isFailedFile(file);
return (
<View className="flex-row items-center gap-8 rounded-12 border border-border-01 px-12 py-8">
<View
testID="file-doc-card"
className={cn(
"max-w-[220px] flex-row items-center gap-8 rounded-12 border px-12 py-8",
failed ? "border-status-error-05" : "border-border-01",
)}
>
{processing ? (
<ActivityIndicator size="small" />
) : (
<Icon as={SvgFileText} size={16} className="text-text-02" />
<Icon
as={failed ? SvgAlertCircle : SvgFileText}
size={16}
className={failed ? "text-status-error-05" : "text-text-02"}
/>
)}
<View className="min-w-0 flex-1">
<View className="min-w-0 shrink">
<Text font="main-ui-body" color="text-04" numberOfLines={1}>
{file.name}
</Text>
<Text
font="secondary-body"
color={processing ? "text-03" : "text-02"}
color={
failed ? "status-error-05" : processing ? "text-03" : "text-02"
}
numberOfLines={1}
>
{statusLabel(file, progress)}
{attachmentStatusLabel(file, progress)}
</Text>
</View>
{canRemove ? (
<Button
icon={SvgX}
prominence="tertiary"
size="sm"
<Pressable
onPress={() => onRemove(file.id)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Remove ${file.name}`}
onPress={onRemove}
/>
>
<Icon as={SvgX} size={16} className="text-text-03" />
</Pressable>
) : null}
</View>
);
}
});
+133 -19
View File
@@ -1,49 +1,143 @@
import { View } from "react-native";
import { useMemo, useState } from "react";
import { Pressable, View, type TextStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { textPresets } from "@onyx-ai/shared/native";
import { ChatState } from "@/chat/interfaces";
import { useRecentFiles } from "@/api/files/files";
import { FileCard } from "@/components/chat/FileCard";
import { FilePickerSheet } from "@/components/chat/FilePickerSheet";
import { Button } from "@/components/ui/button";
import { TextInput } from "@/components/ui/text-input";
import { Text } from "@/components/ui/text";
import { FieldTextInput as ComposerInput } from "@/components/ui/text-input";
import { ChatState } from "@/chat/interfaces";
import { isFailedFile } from "@/lib/files";
import type { UseMessageAttachments } from "@/hooks/useMessageAttachments";
import SvgArrowUp from "@/icons/arrow-up";
import SvgPaperclip from "@/icons/paperclip";
import SvgStop from "@/icons/stop";
// Auto-grow bounds: one line → ~5 lines, then the field scrolls internally.
const INPUT_MIN_HEIGHT = 24;
const INPUT_MAX_HEIGHT = 120;
interface InputBarProps {
value: string;
onChangeText: (text: string) => void;
onSend: () => void;
onStop: () => void;
chatState: ChatState;
attachments: UseMessageAttachments;
}
// Owns its bottom safe-area inset; ChatScreen's KeyboardStickyView lifts it over the keyboard.
// Web-parity composer (mirrors web's AppInputBar shape): a rounded container holding an
// attachment chip strip, an auto-growing multi-line input, and a control row with the
// attach button (left) and send/stop (right). ChatScreen's KeyboardStickyView lifts it
// over the keyboard.
export function InputBar({
value,
onChangeText,
onSend,
onStop,
chatState,
attachments,
}: InputBarProps) {
const insets = useSafeAreaInsets();
const [pickerOpen, setPickerOpen] = useState(false);
const [inputHeight, setInputHeight] = useState(INPUT_MIN_HEIGHT);
const isBusy = chatState === "loading" || chatState === "streaming";
const canSend = value.trim().length > 0 && !isBusy;
const canSend =
value.trim().length > 0 && !isBusy && !attachments.hasBlockingFiles;
// Recent library files (fetched only while the picker is open), minus what's already attached.
const { data: recentFiles = [], isLoading: isLoadingRecent } =
useRecentFiles(pickerOpen);
const linkableRecent = useMemo(() => {
const attachedIds = new Set(attachments.files.map((file) => file.id));
return recentFiles.filter((file) => !attachedIds.has(file.id));
}, [attachments.files, recentFiles]);
const hasFailed = attachments.files.some(isFailedFile);
const blockingMessage = hasFailed
? "Remove the failed attachment to send."
: "Attaching files…";
const clampedHeight = Math.min(
Math.max(inputHeight, INPUT_MIN_HEIGHT),
INPUT_MAX_HEIGHT,
);
return (
<View
className="bg-background-neutral-00 px-16 pt-8"
style={{ paddingBottom: insets.bottom + 8 }}
>
<TextInput
value={value}
onChangeText={onChangeText}
placeholder="Message Onyx…"
returnKeyType="send"
onSubmitEditing={() => {
if (canSend) onSend();
}}
// keep the keyboard up after send
submitBehavior="submit"
rightSlot={
isBusy ? (
{attachments.errors.length > 0 ? (
<Pressable
onPress={attachments.dismissErrors}
className="mb-8 gap-4 rounded-12 border border-border-01 px-12 py-8"
>
{attachments.errors.map((message) => (
<Text key={message} font="secondary-body" color="status-error-05">
{message}
</Text>
))}
<Text font="secondary-body" color="text-03">
Tap to dismiss
</Text>
</Pressable>
) : null}
<View className="rounded-16 border border-border-01 bg-background-neutral-00 pt-8">
{attachments.files.length > 0 ? (
<View className="flex-row flex-wrap gap-8 px-12 pb-8">
{attachments.files.map((file) => (
<FileCard
key={file.id}
file={file}
progress={attachments.progressById.get(file.id)}
onRemove={attachments.removeFile}
/>
))}
</View>
) : null}
<ComposerInput
value={value}
onChangeText={onChangeText}
placeholder="Message Onyx…"
placeholderClassName="text-text-02"
multiline
onContentSizeChange={(event) =>
setInputHeight(event.nativeEvent.contentSize.height)
}
scrollEnabled={inputHeight > INPUT_MAX_HEIGHT}
className="px-16 text-text-04"
style={[
textPresets["main-content-body"] as TextStyle,
{ height: clampedHeight, textAlignVertical: "top" },
]}
/>
{attachments.hasBlockingFiles ? (
<Text
font="secondary-body"
color={hasFailed ? "status-error-05" : "text-02"}
className="px-16 pt-4"
>
{blockingMessage}
</Text>
) : null}
<View className="flex-row items-center justify-between px-8 pb-8 pt-4">
<Button
prominence="tertiary"
size="sm"
icon={SvgPaperclip}
accessibilityLabel="Attach files"
onPress={() => setPickerOpen(true)}
/>
{isBusy ? (
<Button
prominence="tertiary"
icon={SvgStop}
@@ -59,8 +153,28 @@ export function InputBar({
onPress={onSend}
disabled={!canSend}
/>
)
}
)}
</View>
</View>
<FilePickerSheet
visible={pickerOpen}
onClose={() => setPickerOpen(false)}
onUploadDocuments={() => {
setPickerOpen(false);
void attachments.addDocuments();
}}
onUploadPhotos={() => {
setPickerOpen(false);
void attachments.addImages();
}}
recentFiles={linkableRecent}
onPickRecent={(fileId) => {
setPickerOpen(false);
const file = linkableRecent.find((item) => item.id === fileId);
if (file) attachments.addRecent(file);
}}
isLoadingRecent={isLoadingRecent}
/>
</View>
);
+22 -8
View File
@@ -4,17 +4,29 @@ import { memo } from "react";
import { View } from "react-native";
import { Message } from "@/chat/interfaces";
import { fileDescriptorToDisplayFile } from "@/chat/fileDescriptors";
import { FileCard } from "@/components/chat/FileCard";
import { Text } from "@/components/ui/text";
import { usePacketDisplay } from "@/hooks/usePacketDisplay";
function UserMessage({ message }: { message: string }) {
function UserMessage({ node }: { node: Message }) {
const files = node.files.map(fileDescriptorToDisplayFile);
return (
<View className="items-end py-6">
<View className="max-w-[85%] rounded-16 bg-background-tint-02 px-16 py-12">
<Text font="main-content-body" color="text-05">
{message}
</Text>
</View>
{files.length > 0 ? (
<View className="mb-8 max-w-[85%] flex-row flex-wrap justify-end gap-8">
{files.map((file) => (
<FileCard key={file.id} file={file} />
))}
</View>
) : null}
{node.message.length > 0 ? (
<View className="max-w-[85%] rounded-16 bg-background-tint-02 px-16 py-12">
<Text font="main-content-body" color="text-05">
{node.message}
</Text>
</View>
) : null}
</View>
);
}
@@ -48,7 +60,7 @@ function AssistantMessage({ node }: { node: Message }) {
}
function MessageRowComponent({ node }: { node: Message }) {
if (node.type === "user") return <UserMessage message={node.message} />;
if (node.type === "user") return <UserMessage node={node} />;
if (node.type === "error") return <ErrorMessage message={node.message} />;
return <AssistantMessage node={node} />;
}
@@ -60,5 +72,7 @@ export const MessageRow = memo(
prev.node.type === next.node.type &&
prev.node.message === next.node.message &&
prev.node.messageId === next.node.messageId &&
prev.node.packets.length === next.node.packets.length,
prev.node.packets.length === next.node.packets.length &&
// user rows render attachment chips from node.files; re-render if that array is replaced
prev.node.files === next.node.files,
);
@@ -111,14 +111,14 @@ export function ProjectContextPanel({
</Text>
</View>
) : (
<View className="gap-8">
<View className="flex-row flex-wrap gap-8">
{files.map((file) => (
<FileCard
key={file.id}
file={file}
progress={progressById.get(file.id)}
onRemove={() => {
void removeFile(file.id);
onRemove={(id) => {
void removeFile(id);
}}
/>
))}
@@ -4,22 +4,30 @@ import { fireEvent, render, screen } from "@testing-library/react-native";
import { FileCard } from "@/components/chat/FileCard";
import { makeProjectFile } from "@/chat/__tests__/fixtures";
import { UserFileStatus, type ProjectFile } from "@/chat/contracts/projects";
import { ChatFileType } from "@/chat/interfaces";
// The bearer image pulls the config→storage(MMKV) chain; stub it (the image card's own
// View carries the testID we assert on). `() => null` avoids JSX in the factory.
jest.mock("@/components/chat/AttachmentImage", () => ({
AttachmentImage: () => null,
}));
function makeFile(overrides: Partial<ProjectFile> = {}): ProjectFile {
return makeProjectFile({ name: "doc.pdf", ...overrides });
}
describe("FileCard", () => {
it("shows the extension label and a remove button once settled", () => {
it("shows a document as a pill with its extension label + a remove control", () => {
const onRemove = jest.fn();
render(<FileCard file={makeFile({})} onRemove={onRemove} />);
render(<FileCard file={makeFile({ id: "u1" })} onRemove={onRemove} />);
expect(screen.getByTestId("file-doc-card")).toBeTruthy();
expect(screen.getByText("PDF")).toBeTruthy();
fireEvent.press(screen.getByLabelText("Remove doc.pdf"));
expect(onRemove).toHaveBeenCalledTimes(1);
expect(onRemove).toHaveBeenCalledWith("u1");
});
it("treats INDEXING as still-processing (no remove, Indexing… label)", () => {
it("stays removable while indexing (so the user can unblock send)", () => {
render(
<FileCard
file={makeFile({ status: UserFileStatus.INDEXING })}
@@ -28,17 +36,62 @@ describe("FileCard", () => {
);
expect(screen.getByText("Indexing…")).toBeTruthy();
expect(screen.queryByLabelText("Remove doc.pdf")).toBeNull();
expect(screen.getByLabelText("Remove doc.pdf")).toBeTruthy();
});
it("renders upload progress while UPLOADING", () => {
it("renders upload progress and blocks removal while UPLOADING", () => {
render(
<FileCard
file={makeFile({ status: UserFileStatus.UPLOADING })}
progress={0.42}
onRemove={jest.fn()}
/>,
);
expect(screen.getByText("Uploading… 42%")).toBeTruthy();
expect(screen.queryByLabelText("Remove doc.pdf")).toBeNull();
});
it("renders an image as a thumbnail card, not a doc pill", () => {
const onRemove = jest.fn();
render(
<FileCard
file={makeFile({
id: "img1",
name: "pic.png",
chat_file_type: ChatFileType.IMAGE,
status: UserFileStatus.COMPLETED,
})}
onRemove={onRemove}
/>,
);
expect(screen.getByTestId("file-image-card")).toBeTruthy();
expect(screen.queryByTestId("file-doc-card")).toBeNull();
fireEvent.press(screen.getByLabelText("Remove pic.png"));
expect(onRemove).toHaveBeenCalledWith("img1");
});
it("renders a failed image as a pill (not a thumbnail), still removable", () => {
render(
<FileCard
file={makeFile({
name: "broken.png",
chat_file_type: ChatFileType.IMAGE,
status: UserFileStatus.FAILED,
})}
onRemove={jest.fn()}
/>,
);
expect(screen.queryByTestId("file-image-card")).toBeNull();
expect(screen.getByTestId("file-doc-card")).toBeTruthy();
expect(screen.getByText("Failed")).toBeTruthy();
expect(screen.getByLabelText("Remove broken.png")).toBeTruthy();
});
it("is read-only (no remove control) when onRemove is omitted", () => {
render(<FileCard file={makeFile({ status: UserFileStatus.COMPLETED })} />);
expect(screen.queryByLabelText("Remove doc.pdf")).toBeNull();
});
});
+2 -1
View File
@@ -19,7 +19,8 @@ import SvgEyeClosed from "@/icons/eye-closed";
import SvgX from "@/icons/x";
// `placeholderTextColor` is a prop, not a style — bridge a class to it via cssInterop.
const FieldTextInput = cssInterop(RNTextInput, {
// Exported so other bare TextInputs (e.g. the chat composer) reuse the same bridge.
export const FieldTextInput = cssInterop(RNTextInput, {
className: { target: "style" },
placeholderClassName: {
target: false,
@@ -12,6 +12,7 @@ import {
stopChatSession,
} from "@/api/chat/sessions";
import { streamChatMessage, type StreamEvent } from "@/api/chat/stream";
import { ChatFileType, type FileDescriptor } from "@/chat/interfaces";
import { PacketType } from "@/chat/streamingModels";
import { useChatController } from "@/hooks/useChatController";
import { useChatSessionStore } from "@/state/chatSessionStore";
@@ -145,6 +146,34 @@ describe("useChatController", () => {
).toBeNull();
});
it("threads attachment descriptors into the send body and the optimistic user node", async () => {
useChatSessionStore.getState().ensureSession("s1");
streamMock.mockReturnValue(scripted([startPacket("Hi"), endPacket()]));
const files: FileDescriptor[] = [
{
id: "blob-1",
type: ChatFileType.IMAGE,
name: "pic.png",
user_file_id: "u1",
},
];
const { result } = renderHook(() => useChatController("s1"), { wrapper });
act(() => result.current.setInput("look at this"));
await act(async () => {
await result.current.submit(undefined, files);
});
await waitFor(() => expect(result.current.chatState).toBe("input"));
const body = streamMock.mock.calls[0]![0] as unknown as {
file_descriptors: FileDescriptor[];
};
expect(body.file_descriptors).toEqual(files);
expect(result.current.messages[0]!.files).toEqual(files);
});
it("creates a session on the first message of a new chat", async () => {
createSessionMock.mockResolvedValue("new-session");
streamMock.mockReturnValue(scripted([startPacket("Hi"), endPacket()]));
@@ -0,0 +1,427 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import type { Mock } from "jest-mock";
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { getUserFileStatuses } from "@/api/files/files";
import { generateTempId, uploadUserFile } from "@/api/files/upload";
import { pickDocuments, pickImages } from "@/api/files/pickers";
import {
UserFileStatus,
type CategorizedFiles,
type ProjectFile,
} from "@/chat/contracts/projects";
import { ChatFileType } from "@/chat/interfaces";
import { makeProjectFile } from "@/chat/__tests__/fixtures";
import { useMessageAttachments } from "@/hooks/useMessageAttachments";
// `jest.mock` is hoisted above the imports by babel-jest.
const mockSettingsRef = { maxMb: null as number | null };
jest.mock("@/api/files/pickers", () => ({
pickDocuments: jest.fn(),
pickImages: jest.fn(),
}));
jest.mock("@/api/files/upload", () => ({
uploadUserFile: jest.fn(),
generateTempId: jest.fn(),
}));
jest.mock("@/api/files/files", () => ({
getUserFileStatuses: jest.fn(),
}));
jest.mock("@/api/settings", () => ({
useWorkspaceSettings: () => ({
settings: {
disable_default_assistant: false,
user_file_max_upload_size_mb: mockSettingsRef.maxMb,
},
}),
}));
const pickDocumentsMock = pickDocuments as unknown as Mock<
() => Promise<{ uri: string; name: string; size?: number }[]>
>;
const pickImagesMock = pickImages as unknown as Mock<
() => Promise<{ uri: string; name: string; size?: number }[]>
>;
const uploadMock = uploadUserFile as unknown as Mock<
(...args: unknown[]) => Promise<CategorizedFiles>
>;
const statusesMock = getUserFileStatuses as unknown as Mock<
(ids: string[]) => Promise<ProjectFile[]>
>;
const generateTempIdMock = generateTempId as unknown as Mock<() => string>;
function serverFile(overrides: Partial<ProjectFile> = {}): ProjectFile {
return makeProjectFile({ name: "server.pdf", token_count: 9, ...overrides });
}
describe("useMessageAttachments", () => {
let tempIdCounter = 0;
beforeEach(() => {
mockSettingsRef.maxMb = null;
jest.clearAllMocks();
// Reset per test so reconciliation fixtures can rely on the first id being "tmp-1".
tempIdCounter = 0;
generateTempIdMock.mockImplementation(() => `tmp-${++tempIdCounter}`);
});
it("uploads a picked document, reconciles the optimistic entry, and builds descriptors", async () => {
pickDocumentsMock.mockResolvedValue([
{ uri: "file:///a.pdf", name: "a.pdf", size: 1000 },
]);
uploadMock.mockResolvedValue({
user_files: [
serverFile({
id: "user-9",
file_id: "blob-9",
temp_id: "tmp-1",
status: UserFileStatus.COMPLETED,
}),
],
rejected_files: [],
});
const { result } = renderHook(() => useMessageAttachments("chat-1:"));
await act(async () => {
await result.current.addDocuments();
});
// per-message upload sends no project id
expect(uploadMock).toHaveBeenCalledWith(
{ uri: "file:///a.pdf", name: "a.pdf", size: 1000 },
null,
expect.any(String),
expect.any(Function),
);
expect(result.current.files).toHaveLength(1);
expect(result.current.files[0].file_id).toBe("blob-9");
expect(result.current.progressById.size).toBe(0);
expect(result.current.descriptors).toEqual([
{
id: "blob-9",
type: ChatFileType.DOCUMENT,
name: "server.pdf",
user_file_id: "user-9",
},
]);
expect(result.current.hasBlockingFiles).toBe(false);
});
it("blocks send while an attachment is still indexing", async () => {
pickDocumentsMock.mockResolvedValue([
{ uri: "file:///a.pdf", name: "a.pdf", size: 1 },
]);
uploadMock.mockResolvedValue({
user_files: [
serverFile({ temp_id: "tmp-1", status: UserFileStatus.INDEXING }),
],
rejected_files: [],
});
const { result } = renderHook(() => useMessageAttachments("chat-1:"));
await act(async () => {
await result.current.addDocuments();
});
expect(result.current.hasBlockingFiles).toBe(true);
});
it("blocks send on a failed upload until it is removed", async () => {
pickDocumentsMock.mockResolvedValue([
{ uri: "file:///a.pdf", name: "a.pdf", size: 1 },
]);
uploadMock.mockResolvedValue({
user_files: [
serverFile({
id: "u1",
temp_id: "tmp-1",
status: UserFileStatus.FAILED,
}),
],
rejected_files: [],
});
const { result } = renderHook(() => useMessageAttachments("chat-1:"));
await act(async () => {
await result.current.addDocuments();
});
expect(result.current.hasBlockingFiles).toBe(true);
act(() => {
result.current.removeFile("u1");
});
expect(result.current.files).toHaveLength(0);
expect(result.current.hasBlockingFiles).toBe(false);
});
it("blocks oversized files with the size pre-check and never uploads them", async () => {
mockSettingsRef.maxMb = 1;
pickDocumentsMock.mockResolvedValue([
{ uri: "file:///big.pdf", name: "big.pdf", size: 5 * 1024 * 1024 },
]);
const { result } = renderHook(() => useMessageAttachments("chat-1:"));
await act(async () => {
await result.current.addDocuments();
});
expect(uploadMock).not.toHaveBeenCalled();
expect(result.current.errors[0]).toContain("big.pdf");
expect(result.current.files).toHaveLength(0);
});
it("drops the optimistic entry and surfaces the reason when the backend rejects it", async () => {
pickImagesMock.mockResolvedValue([
{ uri: "file:///x.heic", name: "x.heic", size: 10 },
]);
uploadMock.mockResolvedValue({
user_files: [],
rejected_files: [
{ file_name: "x.heic", reason: "Unsupported file type" },
],
});
const { result } = renderHook(() => useMessageAttachments("chat-1:"));
await act(async () => {
await result.current.addImages();
});
expect(result.current.files).toHaveLength(0);
expect(result.current.errors[0]).toContain("Unsupported file type");
});
it("attaches an already-indexed recent file and dedupes by id", () => {
const recent = serverFile({
id: "recent-1",
status: UserFileStatus.COMPLETED,
});
const { result } = renderHook(() => useMessageAttachments("chat-1:"));
act(() => {
result.current.addRecent(recent);
result.current.addRecent(recent); // no duplicate
});
expect(result.current.files).toHaveLength(1);
expect(result.current.descriptors[0].user_file_id).toBe("recent-1");
});
it("clear() empties the draft", () => {
const { result } = renderHook(() => useMessageAttachments("chat-1:"));
act(() => {
result.current.addRecent(serverFile({ id: "r1" }));
});
expect(result.current.files).toHaveLength(1);
act(() => {
result.current.clear();
});
expect(result.current.files).toHaveLength(0);
});
it("resets the draft when the conversation key changes", () => {
const { result, rerender } = renderHook(
({ key }: { key: string }) => useMessageAttachments(key),
{ initialProps: { key: "chat-1:" } },
);
act(() => {
result.current.addRecent(serverFile({ id: "r1" }));
});
expect(result.current.files).toHaveLength(1);
rerender({ key: "chat-2:" });
expect(result.current.files).toHaveLength(0);
});
it("does not resurrect an upload that resolves after the conversation switched", async () => {
pickDocumentsMock.mockResolvedValue([
{ uri: "file:///a.pdf", name: "a.pdf", size: 1 },
]);
let resolveUpload: (value: CategorizedFiles) => void = () => {};
uploadMock.mockReturnValue(
new Promise<CategorizedFiles>((resolve) => {
resolveUpload = resolve;
}),
);
const { result, rerender } = renderHook(
({ key }: { key: string }) => useMessageAttachments(key),
{ initialProps: { key: "chat-1:" } },
);
// Start the upload; wait for the optimistic entry to land under chat-1.
let pending: Promise<void> = Promise.resolve();
act(() => {
pending = result.current.addDocuments();
});
await waitFor(() => expect(result.current.files).toHaveLength(1));
// Switch conversations while the upload is in flight, then let it land.
rerender({ key: "chat-2:" });
expect(result.current.files).toHaveLength(0); // draft reset on switch
await act(async () => {
resolveUpload({
user_files: [serverFile({ temp_id: "tmp-1" })],
rejected_files: [],
});
await pending;
});
// The late reconcile is dropped (guarded by the captured conversation key).
expect(result.current.files).toHaveLength(0);
});
it("polls processing files and patches their status", async () => {
jest.useFakeTimers();
try {
const indexing = serverFile({
id: "u1",
file_id: "blob-1",
status: UserFileStatus.INDEXING,
});
const { result } = renderHook(() => useMessageAttachments("chat-1:"));
act(() => {
result.current.addRecent(indexing);
});
expect(result.current.hasBlockingFiles).toBe(true);
statusesMock.mockResolvedValue([
serverFile({
id: "u1",
file_id: "blob-1",
status: UserFileStatus.COMPLETED,
}),
]);
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => expect(result.current.hasBlockingFiles).toBe(false));
expect(statusesMock).toHaveBeenCalledWith(["u1"]);
} finally {
jest.useRealTimers();
}
});
it("drops the optimistic entry and surfaces an error when the upload throws", async () => {
pickDocumentsMock.mockResolvedValue([
{ uri: "file:///a.pdf", name: "a.pdf", size: 1 },
]);
uploadMock.mockRejectedValue(new Error("network down"));
const { result } = renderHook(() => useMessageAttachments("chat-1:"));
await act(async () => {
await result.current.addDocuments();
});
expect(result.current.files).toHaveLength(0);
expect(result.current.errors[0]).toContain("could not be uploaded");
expect(result.current.hasBlockingFiles).toBe(false);
});
it("surfaces a picker failure inline and attempts no upload", async () => {
pickDocumentsMock.mockRejectedValue(new Error("permission denied"));
const { result } = renderHook(() => useMessageAttachments("chat-1:"));
await act(async () => {
await result.current.addDocuments();
});
expect(uploadMock).not.toHaveBeenCalled();
expect(result.current.errors).toHaveLength(1);
expect(result.current.files).toHaveLength(0);
});
it("clears prior errors at the start of a fresh pick batch", async () => {
mockSettingsRef.maxMb = 1;
pickDocumentsMock.mockResolvedValue([
{ uri: "file:///big.pdf", name: "big.pdf", size: 5 * 1024 * 1024 },
]);
const { result } = renderHook(() => useMessageAttachments("chat-1:"));
await act(async () => {
await result.current.addDocuments();
});
expect(result.current.errors).toHaveLength(1); // oversized rejection
// A new pick starts clean — the stale rejection must not persist/stack.
pickDocumentsMock.mockResolvedValue([
{ uri: "file:///ok.pdf", name: "ok.pdf", size: 10 },
]);
uploadMock.mockResolvedValue({
user_files: [
serverFile({ temp_id: "tmp-1", status: UserFileStatus.COMPLETED }),
],
rejected_files: [],
});
await act(async () => {
await result.current.addDocuments();
});
expect(result.current.errors).toHaveLength(0);
});
it("does not leak an upload error into a conversation the user already left", async () => {
pickDocumentsMock.mockResolvedValue([
{ uri: "file:///a.pdf", name: "a.pdf", size: 1 },
]);
let rejectUpload: (reason?: unknown) => void = () => {};
uploadMock.mockReturnValue(
new Promise<CategorizedFiles>((_resolve, reject) => {
rejectUpload = reject;
}),
);
const { result, rerender } = renderHook(
({ key }: { key: string }) => useMessageAttachments(key),
{ initialProps: { key: "chat-1:" } },
);
let pending: Promise<void> = Promise.resolve();
act(() => {
pending = result.current.addDocuments();
});
await waitFor(() => expect(result.current.files).toHaveLength(1));
// Leave chat-1 mid-upload, then let the upload fail. The failure belongs to a
// conversation the user left, so the ownership guard must drop the error write.
rerender({ key: "chat-2:" });
await act(async () => {
rejectUpload(new Error("network down"));
await pending;
});
expect(result.current.errors).toHaveLength(0);
expect(result.current.files).toHaveLength(0);
});
it("drops files picked while the user switched conversations mid-pick", async () => {
let resolvePick: (
assets: { uri: string; name: string; size?: number }[],
) => void = () => {};
pickDocumentsMock.mockReturnValue(
new Promise((resolve) => {
resolvePick = resolve;
}),
);
const { result, rerender } = renderHook(
({ key }: { key: string }) => useMessageAttachments(key),
{ initialProps: { key: "chat-1:" } },
);
let pending: Promise<void> = Promise.resolve();
act(() => {
pending = result.current.addDocuments();
});
// Switch conversations while the native picker is still open, then let it resolve.
rerender({ key: "chat-2:" });
await act(async () => {
resolvePick([{ uri: "file:///a.pdf", name: "a.pdf", size: 1 }]);
await pending;
});
expect(uploadMock).not.toHaveBeenCalled();
expect(result.current.files).toHaveLength(0);
});
});
@@ -17,7 +17,7 @@ import {
linkFileToProject,
unlinkFileFromProject,
} from "@/api/files/files";
import { uploadProjectFile } from "@/api/files/upload";
import { uploadUserFile } from "@/api/files/upload";
import { pickDocuments, pickImages } from "@/api/files/pickers";
import {
UserFileStatus,
@@ -39,7 +39,7 @@ jest.mock("@/api/files/pickers", () => ({
jest.mock("@/api/files/upload", () => {
let counter = 0;
return {
uploadProjectFile: jest.fn(),
uploadUserFile: jest.fn(),
generateTempId: () => `tmp-${++counter}`,
};
});
@@ -67,7 +67,7 @@ const pickDocumentsMock = pickDocuments as unknown as Mock<
const pickImagesMock = pickImages as unknown as Mock<
() => Promise<{ uri: string; name: string; size?: number }[]>
>;
const uploadMock = uploadProjectFile as unknown as Mock<
const uploadMock = uploadUserFile as unknown as Mock<
(...args: unknown[]) => Promise<CategorizedFiles>
>;
const statusesMock = getUserFileStatuses as unknown as Mock<
+7 -6
View File
@@ -20,7 +20,7 @@ import {
import { DEFAULT_AGENT_ID } from "@/chat/agents";
import { FLUSH_INTERVAL_MS } from "@/chat/constants";
import { processRawChatHistory } from "@/chat/chatHistory";
import { ChatState } from "@/chat/interfaces";
import { ChatState, FileDescriptor } from "@/chat/interfaces";
import {
buildImmediateMessages,
getLastSuccessfulMessageId,
@@ -149,8 +149,8 @@ export interface ChatController {
input: string;
setInput: (value: string) => void;
// `overrideMessage` sends a specific string (e.g. a tapped starter prompt) instead of
// the composer's current input.
submit: (overrideMessage?: string) => void;
// the composer's current input. `files` are the message's attachment descriptors.
submit: (overrideMessage?: string, files?: FileDescriptor[]) => void;
stop: () => void;
isHydrating: boolean;
}
@@ -206,9 +206,10 @@ export function useChatController(
const chatState: ChatState = sessionData?.chatState ?? "input";
const submit = useCallback(
async (overrideMessage?: string) => {
async (overrideMessage?: string, files?: FileDescriptor[]) => {
const text = (overrideMessage ?? input).trim();
if (!text) return;
const fileDescriptors = files ?? [];
if (submittingRef.current) return;
submittingRef.current = true;
try {
@@ -250,7 +251,7 @@ export function useChatController(
const { initialUserNode, initialAgentNode } = buildImmediateMessages(
parentNodeId,
text,
[],
fileDescriptors,
);
store.updateSessionTree(
activeId,
@@ -266,7 +267,7 @@ export function useChatController(
message: text,
chat_session_id: activeId,
parent_message_id: parentMessageId,
file_descriptors: [],
file_descriptors: fileDescriptors,
deep_research: false,
origin: "mobile",
};
+278
View File
@@ -0,0 +1,278 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getErrorMessage } from "@/api/errors";
import { getUserFileStatuses } from "@/api/files/files";
import { pickDocuments, pickImages } from "@/api/files/pickers";
import {
generateTempId,
uploadUserFile,
type NormalizedAsset,
} from "@/api/files/upload";
import { useWorkspaceSettings } from "@/api/settings";
import { projectFilesToFileDescriptors } from "@/chat/fileDescriptors";
import {
isProcessingStatus,
UserFileStatus,
type ProjectFile,
} from "@/chat/contracts/projects";
import { type FileDescriptor } from "@/chat/interfaces";
import {
buildOptimisticFile,
isFailedFile,
partitionBySize,
} from "@/lib/files";
const POLL_INTERVAL_MS = 3000;
// PROCESSING/INDEXING are server-side and pollable; UPLOADING is client-only
// (its `id` is a temp id the status endpoint wouldn't know).
function isServerProcessing(file: ProjectFile): boolean {
const upper = String(file.status).toUpperCase();
return (
upper === UserFileStatus.PROCESSING || upper === UserFileStatus.INDEXING
);
}
export interface UseMessageAttachments {
files: ProjectFile[];
progressById: Map<string, number>;
errors: string[];
descriptors: FileDescriptor[];
// any file still uploading/indexing OR failed → block send (removal unblocks)
hasBlockingFiles: boolean;
addDocuments: () => Promise<void>;
addImages: () => Promise<void>;
addRecent: (file: ProjectFile) => void;
removeFile: (id: string) => void;
dismissErrors: () => void;
clear: () => void;
}
// Per-message draft attachments: pick → upload (unlinked user file) → poll status →
// build descriptors → clear on send. Web-faithful equivalent of `currentMessageFiles`.
// State is LOCAL (never a store) and reset whenever `resetKey` changes, because the
// composer lives in one persistent ChatSurface that never remounts across conversations
// — so attachments must not leak across `[sessionId, projectId]`. All async writes are
// guarded by a captured `resetKey` + matched by temp id, so a late upload/poll for a
// conversation the user already left is dropped, never resurrected.
export function useMessageAttachments(resetKey: string): UseMessageAttachments {
const { settings } = useWorkspaceSettings();
const maxUploadMb = settings.user_file_max_upload_size_mb;
const [files, setFiles] = useState<ProjectFile[]>([]);
const [progressById, setProgressById] = useState<Map<string, number>>(
() => new Map(),
);
const [errors, setErrors] = useState<string[]>([]);
// The current conversation key, read non-reactively from async callbacks.
const keyRef = useRef(resetKey);
keyRef.current = resetKey;
const clear = useCallback(() => {
setFiles([]);
setProgressById(new Map());
setErrors([]);
}, []);
// Conversation changed → drop the previous conversation's draft. Runs on mount too
// (a no-op on empty state).
useEffect(() => {
clear();
}, [resetKey, clear]);
const runUpload = useCallback(
async (assets: NormalizedAsset[]) => {
if (assets.length === 0) return;
const myKey = keyRef.current;
const isCurrent = () => keyRef.current === myKey;
// Size pre-check mirrors web's client-side guard.
const { valid, rejections: sizeRejections } = partitionBySize(
assets,
maxUploadMb,
);
if (sizeRejections.length > 0 && isCurrent()) {
setErrors((prev) => [...prev, ...sizeRejections]);
}
if (valid.length === 0) return;
const items = valid.map((asset) => ({ asset, tempId: generateTempId() }));
if (!isCurrent()) return;
setFiles((prev) => [
...prev,
...items.map(({ asset, tempId }) => buildOptimisticFile(asset, tempId)),
]);
const uploadErrors: string[] = [];
await Promise.all(
items.map(async ({ asset, tempId }) => {
try {
const result = await uploadUserFile(
asset,
null,
tempId,
(ratio) => {
if (isCurrent()) {
setProgressById((prev) => new Map(prev).set(tempId, ratio));
}
},
);
const created = result.user_files[0];
result.rejected_files.forEach((file) =>
uploadErrors.push(`${file.file_name}: ${file.reason}`),
);
if (!isCurrent()) return;
// Reconcile the optimistic entry (matched by temp id) with the server
// file; if it was rejected, drop it. A missing temp id means the user
// switched conversations — skip.
setFiles((prev) => {
const index = prev.findIndex((file) => file.temp_id === tempId);
if (index < 0) return prev;
const next = [...prev];
if (created) next[index] = created;
else next.splice(index, 1);
return next;
});
} catch (error) {
console.warn(`attachment upload failed for ${asset.name}`, error);
uploadErrors.push(`${asset.name} could not be uploaded`);
if (isCurrent()) {
setFiles((prev) =>
prev.filter((file) => file.temp_id !== tempId),
);
}
} finally {
if (isCurrent()) {
setProgressById((prev) => {
const next = new Map(prev);
next.delete(tempId);
return next;
});
}
}
}),
);
if (uploadErrors.length > 0 && isCurrent()) {
setErrors((prev) => [...prev, ...uploadErrors]);
}
},
[maxUploadMb],
);
// Surface a picker permission denial / native error inline (the picker throws
// rather than returning empty on denial).
const runPicked = useCallback(
async (pick: () => Promise<NormalizedAsset[]>) => {
// Capture the draft key before the picker opens; if the user switches
// conversations while it's open, drop the picked files and any error so they
// can't land in the new chat.
const myKey = keyRef.current;
// A fresh pick starts from a clean error state (mirrors useProjectFiles),
// so stale/duplicate rejections don't stack across attempts.
setErrors([]);
try {
const assets = await pick();
if (keyRef.current !== myKey) return;
await runUpload(assets);
} catch (error) {
if (keyRef.current !== myKey) return;
console.warn("file picker failed", error);
setErrors((prev) => [
...prev,
getErrorMessage(error, "Couldn't open the file picker."),
]);
}
},
[runUpload],
);
const addDocuments = useCallback(() => runPicked(pickDocuments), [runPicked]);
const addImages = useCallback(() => runPicked(pickImages), [runPicked]);
// Attach an already-indexed recent file (web's onPickRecent — purely client-side,
// no server link for a per-message attachment).
const addRecent = useCallback((file: ProjectFile) => {
setFiles((prev) =>
prev.some((existing) => existing.id === file.id) ? prev : [...prev, file],
);
}, []);
const removeFile = useCallback((id: string) => {
setFiles((prev) => prev.filter((file) => file.id !== id));
setProgressById((prev) => {
if (!prev.has(id)) return prev;
const next = new Map(prev);
next.delete(id);
return next;
});
}, []);
const dismissErrors = useCallback(() => setErrors([]), []);
// Poll server-side processing files, patching local state. `trackedKey` is a stable
// string so the effect doesn't reset the timer every render.
const trackedIds = useMemo(
() =>
files
.filter(isServerProcessing)
.map((file) => file.id)
.sort(),
[files],
);
const trackedKey = trackedIds.join(",");
useEffect(() => {
if (trackedIds.length === 0) return;
const myKey = keyRef.current;
let cancelled = false;
const interval = setInterval(() => {
void (async () => {
try {
const statuses = await getUserFileStatuses(trackedIds);
if (cancelled || keyRef.current !== myKey) return;
const byId = new Map(statuses.map((file) => [file.id, file]));
setFiles((prev) => prev.map((file) => byId.get(file.id) ?? file));
} catch (error) {
// transient poll failure — retry next tick
console.warn("attachment status poll failed", error);
}
})();
}, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
// trackedIds is re-read via trackedKey (stable when its contents are unchanged)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trackedKey]);
const descriptors = useMemo(
() => projectFilesToFileDescriptors(files),
[files],
);
const hasBlockingFiles = useMemo(
() =>
files.some(
(file) => isProcessingStatus(file.status) || isFailedFile(file),
),
[files],
);
return {
files,
progressById,
errors,
descriptors,
hasBlockingFiles,
addDocuments,
addImages,
addRecent,
removeFile,
dismissErrors,
clear,
};
}
+8 -32
View File
@@ -9,39 +9,23 @@ import {
} from "@/api/files/files";
import {
generateTempId,
uploadProjectFile,
uploadUserFile,
type NormalizedAsset,
} from "@/api/files/upload";
import { pickDocuments, pickImages } from "@/api/files/pickers";
import { getErrorMessage } from "@/api/errors";
import { useWorkspaceSettings } from "@/api/settings";
import { ChatFileType } from "@/chat/interfaces";
import {
isProcessingStatus,
UserFileStatus,
type ProjectDetails,
type ProjectFile,
} from "@/chat/contracts/projects";
import { buildOptimisticFile, partitionBySize } from "@/lib/files";
import { useProjectUploads, useUploadStore } from "@/state/uploadStore";
import { useSession } from "@/state/session";
const POLL_INTERVAL_MS = 3000;
function optimisticFile(asset: NormalizedAsset, tempId: string): ProjectFile {
return {
id: tempId,
temp_id: tempId,
name: asset.name,
file_id: tempId,
status: UserFileStatus.UPLOADING,
chat_file_type: asset.mimeType?.startsWith("image/")
? ChatFileType.IMAGE
: ChatFileType.DOCUMENT,
token_count: null,
created_at: new Date().toISOString(),
};
}
export interface UseProjectFiles {
// Optimistic (uploading) entries first, then committed files.
files: ProjectFile[];
@@ -101,18 +85,10 @@ export function useProjectFiles(
if (projectId == null || assets.length === 0) return;
// Size pre-check mirrors web's client-side guard.
const maxBytes =
maxUploadMb != null && maxUploadMb > 0
? maxUploadMb * 1024 * 1024
: null;
const rejected: string[] = [];
const valid = assets.filter((asset) => {
if (maxBytes != null && asset.size != null && asset.size > maxBytes) {
rejected.push(`${asset.name} exceeds the ${maxUploadMb} MB limit`);
return false;
}
return true;
});
const { valid, rejections: rejected } = partitionBySize(
assets,
maxUploadMb,
);
if (valid.length === 0) {
setErrors(projectId, rejected);
@@ -122,7 +98,7 @@ export function useProjectFiles(
const items = valid.map((asset) => ({ asset, tempId: generateTempId() }));
begin(
projectId,
items.map(({ asset, tempId }) => optimisticFile(asset, tempId)),
items.map(({ asset, tempId }) => buildOptimisticFile(asset, tempId)),
);
// begin() clears errors; re-surface size rejections now so they show while
// the uploads are still in flight.
@@ -133,7 +109,7 @@ export function useProjectFiles(
await Promise.all(
items.map(async ({ asset, tempId }) => {
try {
const result = await uploadProjectFile(
const result = await uploadUserFile(
asset,
projectId,
tempId,
+23
View File
@@ -0,0 +1,23 @@
import Svg, { Path } from "react-native-svg";
import type { IconProps } from "@/icons/types";
const SvgPaperclip = ({ size = 16, ...props }: IconProps) => (
<Svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
{...props}
>
<Path
d="M12.0924 3.99814L12.0924 10.6626C12.0924 11.724 11.6707 12.742 10.9202 13.4926C10.1696 14.2431 9.15163 14.6648 8.09018 14.6648C7.02872 14.6648 6.01074 14.2431 5.26018 13.4926C4.50961 12.742 4.08795 11.724 4.08795 10.6626L4.08795 3.99814C4.08795 3.2905 4.36906 2.61184 4.86944 2.11147C5.36981 1.6111 6.04847 1.32999 6.7561 1.32999C7.46374 1.32999 8.14239 1.61109 8.64277 2.11147C9.14314 2.61184 9.42425 3.2905 9.42425 3.99814L9.41954 10.6673C9.41954 11.0211 9.27898 11.3604 9.0288 11.6106C8.77861 11.8608 8.43928 12.0013 8.08546 12.0013C7.73164 12.0013 7.39232 11.8608 7.14213 11.6106C6.89194 11.3604 6.75139 11.0211 6.75139 10.6673L6.7561 4.66753"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
export default SvgPaperclip;
+73
View File
@@ -1,3 +1,8 @@
import { UserFileStatus, type ProjectFile } from "@/chat/contracts/projects";
import { ChatFileType } from "@/chat/interfaces";
// Type-only, so this pure module never pulls the upload transport (and its config/MMKV chain).
import type { NormalizedAsset } from "@/api/files/upload";
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "heic", "bmp"];
// Uppercase extension without the dot; "" when there's no usable extension.
@@ -10,3 +15,71 @@ export function extensionOf(name: string): string {
export function isImageName(name: string): boolean {
return IMAGE_EXTENSIONS.includes(extensionOf(name).toLowerCase());
}
// Status casing isn't guaranteed by the payload.
export function isFailedFile(file: ProjectFile): boolean {
return String(file.status).toUpperCase() === UserFileStatus.FAILED;
}
// Optimistic record shown immediately on pick, before the server responds. `id`/`file_id`
// are the temp id until the upload reconciles. Shared by the project + per-message flows.
export function buildOptimisticFile(
asset: NormalizedAsset,
tempId: string,
): ProjectFile {
return {
id: tempId,
temp_id: tempId,
name: asset.name,
file_id: tempId,
status: UserFileStatus.UPLOADING,
chat_file_type: asset.mimeType?.startsWith("image/")
? ChatFileType.IMAGE
: ChatFileType.DOCUMENT,
token_count: null,
created_at: new Date().toISOString(),
};
}
// Client-side upload size guard (mirrors web). Splits picked assets into those within the
// limit and a rejection message per oversized file. Shared by the project + per-message flows.
export function partitionBySize(
assets: NormalizedAsset[],
maxUploadMb: number | null,
): { valid: NormalizedAsset[]; rejections: string[] } {
const maxBytes =
maxUploadMb != null && maxUploadMb > 0 ? maxUploadMb * 1024 * 1024 : null;
const rejections: string[] = [];
const valid = assets.filter((asset) => {
if (maxBytes != null && asset.size != null && asset.size > maxBytes) {
rejections.push(`${asset.name} exceeds the ${maxUploadMb} MB limit`);
return false;
}
return true;
});
return { valid, rejections };
}
// Human status line for a file card/chip. When idle, falls back to the extension
// ("File" avoids a lone filename) — matching web, which leaves it blank.
export function attachmentStatusLabel(
file: ProjectFile,
progress?: number,
): string {
switch (String(file.status).toUpperCase()) {
case UserFileStatus.UPLOADING:
return progress != null
? `Uploading… ${Math.round(progress * 100)}%`
: "Uploading…";
case UserFileStatus.PROCESSING:
return "Processing…";
case UserFileStatus.INDEXING:
return "Indexing…";
case UserFileStatus.DELETING:
return "Deleting…";
case UserFileStatus.FAILED:
return "Failed";
default:
return extensionOf(file.name) || "File";
}
}