mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 13:06:20 +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>
101 lines
2.7 KiB
TypeScript
101 lines
2.7 KiB
TypeScript
"use client";
|
|
|
|
import type { ReactNode } from "react";
|
|
import { ArrowUp, Loader2, Square } from "lucide-react";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@multica/ui/components/ui/tooltip";
|
|
|
|
interface SubmitButtonProps {
|
|
onClick: () => void;
|
|
disabled?: boolean;
|
|
loading?: boolean;
|
|
/**
|
|
* Blocked on background work the user started (an attachment still
|
|
* uploading) rather than on a missing precondition. Disables the button and
|
|
* marks it `aria-busy`, so a screen reader reaching it with a virtual cursor
|
|
* reads "busy" rather than an unexplained dead control. It is NOT tab
|
|
* reachable while disabled, so callers should also carry the reason in
|
|
* `ariaLabel` / `tooltip` and in the visible label where there is one.
|
|
*/
|
|
busy?: boolean;
|
|
running?: boolean;
|
|
onStop?: () => void;
|
|
/**
|
|
* Tooltip shown over the send button when idle. Pass a string or a node
|
|
* (e.g. `Send · ⌘↵`). Omit to render no tooltip.
|
|
* Callers compose the shortcut hint themselves to keep this component
|
|
* free of `@multica/core` (platform-detection) and i18n imports.
|
|
*/
|
|
tooltip?: ReactNode;
|
|
/** Accessible name for the icon-only submit button. */
|
|
ariaLabel?: string;
|
|
/** Tooltip shown over the stop button while a run is in progress. */
|
|
stopTooltip?: ReactNode;
|
|
/** Accessible name for the icon-only stop button. */
|
|
stopAriaLabel?: string;
|
|
}
|
|
|
|
function SubmitButton({
|
|
onClick,
|
|
disabled,
|
|
loading,
|
|
busy,
|
|
running,
|
|
onStop,
|
|
tooltip,
|
|
ariaLabel,
|
|
stopTooltip,
|
|
stopAriaLabel,
|
|
}: SubmitButtonProps) {
|
|
if (running) {
|
|
const stopButton = (
|
|
<Button
|
|
size="icon-sm"
|
|
className="rounded-full"
|
|
onClick={onStop}
|
|
aria-label={stopAriaLabel}
|
|
>
|
|
<Square className="fill-current" aria-hidden="true" />
|
|
</Button>
|
|
);
|
|
if (!stopTooltip) return stopButton;
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger render={stopButton} />
|
|
<TooltipContent side="top">{stopTooltip}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
const submitButton = (
|
|
<Button
|
|
size="icon-sm"
|
|
className="rounded-full"
|
|
disabled={disabled || loading || busy}
|
|
aria-disabled={busy || undefined}
|
|
aria-busy={busy || undefined}
|
|
onClick={onClick}
|
|
aria-label={ariaLabel}
|
|
>
|
|
{loading ? (
|
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
|
) : (
|
|
<ArrowUp aria-hidden="true" />
|
|
)}
|
|
</Button>
|
|
);
|
|
if (!tooltip) return submitButton;
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger render={submitButton} />
|
|
<TooltipContent side="top">{tooltip}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
export { SubmitButton, type SubmitButtonProps };
|