Files
multica/packages/ui/components/common/submit-button.tsx
Naiyuan Qing 3b08456264 feat(views): block draft-fixing actions while attachments upload (#5465)
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>
2026-07-16 09:56:24 +08:00

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 };