mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
Submitting a composer mid-upload silently lost the file: the editor holds a `blob:` placeholder that gets stripped during serialization, and the attachment id does not exist yet, so the send succeeded without the file. Chat and Quick Create gated this; manual issue create, Feedback's button, comments, replies and comment edit did not. Gate every action that FIXES a draft — Send/Create/Save, Cmd/Ctrl+Enter, Enter on the title, and the manual/agent mode switches — behind one source of truth: the editor document, which IS the upload queue. ContentEditor publishes queue transitions via `onUploadingChange` (a transaction listener, not `onUpdate` — that path is debounced and skips no-change emissions, and a failed upload leaves byte-identical markdown, so the un-gate would never fire). `useUploadGate` pairs that render state with an `isBlocked()` re-read at submit time, since the shortcut paths never consult the button. The publisher emits its current answer on subscribe rather than only on flips: hosts outlive editor instances (comment edit remounts on cancel, chat swaps by key on agent switch), and an editor torn down mid-upload would otherwise leave submit wedged shut with no pending node left to reopen it. Also fixes the failure toast that never fired: `uploadWithToast` only reported errors if a caller passed `onError`, and none did, so failed uploads removed their placeholder and vanished unexplained. `useEditorUpload` now supplies it once for every composer. Per MUL-4808: drops Quick Create's upload-time lock on the attach button (files can queue), and leaves description autosave ungated by design. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
142 lines
6.3 KiB
TypeScript
142 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback } from "react";
|
|
import type { ApiClient } from "../api/client";
|
|
import type { Attachment } from "../types";
|
|
import { attachmentDownloadPath } from "../types/attachment-url";
|
|
import { MAX_FILE_SIZE } from "../constants/upload";
|
|
|
|
// Carries the full Attachment so editors that need preview metadata
|
|
// (`content_type`, `download_url`) get it directly. Two URL fields are
|
|
// surfaced because they have different lifetimes:
|
|
//
|
|
// `link` — the same value as `att.url`. Short-lived for the
|
|
// LocalStorage backend (HMAC-signed `/uploads/<key>`)
|
|
// and a long-lived CDN URL on S3 / CloudFront. This
|
|
// is what avatar / logo callers persist into
|
|
// `avatar_url` style fields, and what URL-only
|
|
// consumers (Markdown renderers without a record
|
|
// in hand) get to load directly. Keeping it
|
|
// semantically equal to `att.url` preserves the
|
|
// pre-MUL-3130 contract for non-markdown callers
|
|
// so avatar uploads do not get rerouted through
|
|
// the workspace-membership-gated download endpoint.
|
|
//
|
|
// `markdownLink` — the URL the editor writes into markdown bodies.
|
|
// Source: `att.markdown_url` from the server, which
|
|
// `buildMarkdownURL` picks per deployment policy
|
|
// (public CDN durable URL, or
|
|
// `<MULTICA_PUBLIC_URL>/api/attachments/<id>/download`).
|
|
// The contract is "absolute, no TTL, loads natively
|
|
// on every client" — that's what fixes the Desktop /
|
|
// mobile-webview regression where a site-relative
|
|
// `/api/attachments/<id>/download` couldn't resolve
|
|
// against `file://` (MUL-3192).
|
|
//
|
|
// Falls back through two layers when the server
|
|
// didn't populate `markdown_url`:
|
|
// 1. `attachmentDownloadPath(att.id)` — the legacy
|
|
// site-relative shape. Works on web (Next
|
|
// rewrite proxies /api/* to the API host) and
|
|
// is what older comments persist; render
|
|
// surfaces handle the absolutize for non-web
|
|
// clients via attachment.tsx's legacy compat.
|
|
// 2. `att.url` — the no-workspace avatar branch
|
|
// where there's no attachment-row id at all.
|
|
//
|
|
// MUL-3130 introduced the persisted-image regression by collapsing
|
|
// these two semantics into a single `link` field; MUL-3192 followed up
|
|
// by moving the durable-URL choice from the client to the server so
|
|
// the persisted shape is correct for the deployment without the client
|
|
// having to know whether it's running on web / desktop / mobile.
|
|
export type UploadResult = Attachment & {
|
|
link: string;
|
|
markdownLink: string;
|
|
};
|
|
|
|
export interface UploadContext {
|
|
issueId?: string;
|
|
commentId?: string;
|
|
chatSessionId?: string;
|
|
}
|
|
|
|
// pickMarkdownLink chooses the URL the editor will write into markdown.
|
|
//
|
|
// Order:
|
|
// 1. `att.markdown_url` — server-provided durable URL. This is the
|
|
// modern contract introduced in MUL-3192; the server (`buildMarkdownURL`)
|
|
// decides whether to emit a public CDN URL or an absolute API
|
|
// endpoint pinned to `MULTICA_PUBLIC_URL` based on the deployment.
|
|
// 2. `attachmentDownloadPath(att.id)` — site-relative legacy shape,
|
|
// retained for compatibility with backends old enough to predate
|
|
// MUL-3192. Web's Next rewrite makes this load; desktop / mobile
|
|
// surfaces hit the attachment.tsx legacy-absolutize fallback.
|
|
// 3. `att.url` — no attachment-row id (the no-workspace avatar branch
|
|
// of UploadFile). Markdown callers fall back to whatever storage
|
|
// backend produced for the upload; persistence is on the caller.
|
|
function pickMarkdownLink(att: Attachment): string {
|
|
if (att.markdown_url) return att.markdown_url;
|
|
if (att.id) return attachmentDownloadPath(att.id);
|
|
return att.url;
|
|
}
|
|
|
|
export function useFileUpload(
|
|
api: ApiClient,
|
|
// Receives the failing `file` alongside the error so hosts can name it in
|
|
// the failure toast. `uploadWithToast` swallows the rejection after this
|
|
// fires, so a host that passes nothing reports nothing — the upload
|
|
// placeholder still disappears, which on its own reads as "my file
|
|
// silently vanished" (MUL-4808).
|
|
onError?: (error: Error, file: File) => void,
|
|
) {
|
|
// In-flight counter, NOT a single boolean. Callers fire multiple uploads
|
|
// concurrently (drag-drop of N files, paste with multiple images) and the
|
|
// boolean shape would flip false as soon as the FIRST upload's finally ran
|
|
// — even though N-1 are still mid-request. Surfaces consuming `uploading`
|
|
// (the quick-create submit gate, the editor's "Uploading…" button label)
|
|
// would then unblock submit while uploads are still in flight, causing
|
|
// `stripBlobUrls` to erase the still-pending images from the markdown and
|
|
// their attachment ids never to be bound (MUL-3339).
|
|
//
|
|
// The exposed `uploading: boolean` keeps the existing call-site contract
|
|
// (`{ uploading } = useFileUpload(api)` everywhere); only the internal
|
|
// tracking shape changes.
|
|
const [inFlight, setInFlight] = useState(0);
|
|
const uploading = inFlight > 0;
|
|
|
|
const upload = useCallback(
|
|
async (file: File, ctx?: UploadContext): Promise<UploadResult | null> => {
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
throw new Error("File exceeds 100 MB limit");
|
|
}
|
|
|
|
setInFlight((n) => n + 1);
|
|
try {
|
|
const att: Attachment = await api.uploadFile(file, {
|
|
issueId: ctx?.issueId,
|
|
commentId: ctx?.commentId,
|
|
chatSessionId: ctx?.chatSessionId,
|
|
});
|
|
return { ...att, link: att.url, markdownLink: pickMarkdownLink(att) };
|
|
} finally {
|
|
setInFlight((n) => n - 1);
|
|
}
|
|
},
|
|
[api],
|
|
);
|
|
|
|
const uploadWithToast = useCallback(
|
|
async (file: File, ctx?: UploadContext): Promise<UploadResult | null> => {
|
|
try {
|
|
return await upload(file, ctx);
|
|
} catch (err) {
|
|
onError?.(err instanceof Error ? err : new Error("Upload failed"), file);
|
|
return null;
|
|
}
|
|
},
|
|
[upload, onError],
|
|
);
|
|
|
|
return { upload, uploadWithToast, uploading };
|
|
}
|