@cellarnode/ui 0.172.0 → 0.173.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat/chat-api.d.ts.map +1 -1
- package/dist/chat/chat-api.js +12 -2
- package/dist/chat/chat-api.js.map +1 -1
- package/dist/chat/chat-composer.d.ts +67 -0
- package/dist/chat/chat-composer.d.ts.map +1 -0
- package/dist/chat/chat-composer.js +482 -0
- package/dist/chat/chat-composer.js.map +1 -0
- package/dist/chat/chat-labels.d.ts +5 -0
- package/dist/chat/chat-labels.d.ts.map +1 -0
- package/dist/chat/chat-labels.js +8 -0
- package/dist/chat/chat-labels.js.map +1 -0
- package/dist/chat/chat-surface.d.ts +127 -26
- package/dist/chat/chat-surface.d.ts.map +1 -1
- package/dist/chat/chat-surface.js +103 -45
- package/dist/chat/chat-surface.js.map +1 -1
- package/dist/chat/index.d.ts +2 -2
- package/dist/chat/index.d.ts.map +1 -1
- package/dist/chat/index.js.map +1 -1
- package/dist/chat/messenger-screen/index.d.ts +1 -1
- package/dist/chat/messenger-screen/index.d.ts.map +1 -1
- package/dist/chat/messenger-screen/message-adapters.d.ts +4 -2
- package/dist/chat/messenger-screen/message-adapters.d.ts.map +1 -1
- package/dist/chat/messenger-screen/message-adapters.js +52 -6
- package/dist/chat/messenger-screen/message-adapters.js.map +1 -1
- package/dist/chat/messenger-screen/messenger-screen.d.ts.map +1 -1
- package/dist/chat/messenger-screen/messenger-screen.js +2 -2
- package/dist/chat/messenger-screen/messenger-screen.js.map +1 -1
- package/dist/chat/messenger-screen/room-panel.d.ts +9 -1
- package/dist/chat/messenger-screen/room-panel.d.ts.map +1 -1
- package/dist/chat/messenger-screen/room-panel.js +114 -19
- package/dist/chat/messenger-screen/room-panel.js.map +1 -1
- package/dist/chat/messenger-screen/types.d.ts +49 -2
- package/dist/chat/messenger-screen/types.d.ts.map +1 -1
- package/dist/chat/types.d.ts +33 -5
- package/dist/chat/types.d.ts.map +1 -1
- package/dist/chat-ably/use-chat-messages.d.ts +33 -6
- package/dist/chat-ably/use-chat-messages.d.ts.map +1 -1
- package/dist/chat-ably/use-chat-messages.js +116 -17
- package/dist/chat-ably/use-chat-messages.js.map +1 -1
- package/dist/components/importer-picker-modal.d.ts +5 -0
- package/dist/components/importer-picker-modal.d.ts.map +1 -1
- package/dist/components/importer-picker-modal.js +3 -2
- package/dist/components/importer-picker-modal.js.map +1 -1
- package/dist/product-editor/index.d.ts +2 -0
- package/dist/product-editor/index.d.ts.map +1 -1
- package/dist/product-editor/index.js +1 -0
- package/dist/product-editor/index.js.map +1 -1
- package/dist/product-editor/product-readiness-approval-dialog.d.ts +23 -0
- package/dist/product-editor/product-readiness-approval-dialog.d.ts.map +1 -0
- package/dist/product-editor/product-readiness-approval-dialog.js +101 -0
- package/dist/product-editor/product-readiness-approval-dialog.js.map +1 -0
- package/dist/product-editor/tabs/product-status-tab.d.ts +2 -3
- package/dist/product-editor/tabs/product-status-tab.d.ts.map +1 -1
- package/dist/product-editor/tabs/product-status-tab.js +34 -40
- package/dist/product-editor/tabs/product-status-tab.js.map +1 -1
- package/dist/realtime/journey-offers/schemas.d.ts +21 -6
- package/dist/realtime/journey-offers/schemas.d.ts.map +1 -1
- package/dist/realtime/journey-offers/schemas.js +16 -10
- package/dist/realtime/journey-offers/schemas.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chat-api.d.ts","sourceRoot":"","sources":["../../src/chat/chat-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,
|
|
1
|
+
{"version":3,"file":"chat-api.d.ts","sourceRoot":"","sources":["../../src/chat/chat-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAyD,MAAM,SAAS,CAAC;AAEpG;;;GAGG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;AAE5E;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,UAAU,GAAG,aAAa,CAiDjE"}
|
package/dist/chat/chat-api.js
CHANGED
|
@@ -14,10 +14,20 @@ export function createChatApi(apiFetch) {
|
|
|
14
14
|
const qs = searchParams.toString();
|
|
15
15
|
return apiFetch(`/chat/rooms/${roomId}/messages${qs ? `?${qs}` : ""}`);
|
|
16
16
|
},
|
|
17
|
-
async sendMessage(roomId, content, messageType = "text", metadata) {
|
|
17
|
+
async sendMessage(roomId, content, messageType = "text", metadata, options) {
|
|
18
18
|
return apiFetch(`/chat/rooms/${roomId}/messages`, {
|
|
19
19
|
method: "POST",
|
|
20
|
-
|
|
20
|
+
// Spread rather than always setting the key: the route branches on
|
|
21
|
+
// `replyToMessageId !== undefined`, so sending an explicit null on an
|
|
22
|
+
// ordinary message would earn a 422 instead of a plain send.
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
content,
|
|
25
|
+
messageType,
|
|
26
|
+
metadata,
|
|
27
|
+
...(options?.replyToMessageId !== undefined
|
|
28
|
+
? { replyToMessageId: options.replyToMessageId }
|
|
29
|
+
: {}),
|
|
30
|
+
}),
|
|
21
31
|
});
|
|
22
32
|
},
|
|
23
33
|
async markRead(roomId, messageId) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chat-api.js","sourceRoot":"","sources":["../../src/chat/chat-api.ts"],"names":[],"mappings":"AAQA;;;;;GAKG;AACH,MAAM,UAAU,aAAa,CAAC,QAAoB;IAChD,OAAO;QACL,KAAK,CAAC,WAAW,CAAC,MAAc,EAAE,MAAe;YAC/C,MAAM,YAAY,GAAG,IAAI,eAAe,EAAE,CAAC;YAC3C,IAAI,MAAM;gBAAE,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;YAC/C,YAAY,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;YAChC,MAAM,EAAE,GAAG,YAAY,CAAC,QAAQ,EAAE,CAAC;YACnC,OAAO,QAAQ,CACb,eAAe,MAAM,YAAY,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CACtD,CAAC;QACJ,CAAC;QAED,KAAK,CAAC,WAAW,CACf,MAAc,EACd,OAAe,EACf,
|
|
1
|
+
{"version":3,"file":"chat-api.js","sourceRoot":"","sources":["../../src/chat/chat-api.ts"],"names":[],"mappings":"AAQA;;;;;GAKG;AACH,MAAM,UAAU,aAAa,CAAC,QAAoB;IAChD,OAAO;QACL,KAAK,CAAC,WAAW,CAAC,MAAc,EAAE,MAAe;YAC/C,MAAM,YAAY,GAAG,IAAI,eAAe,EAAE,CAAC;YAC3C,IAAI,MAAM;gBAAE,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;YAC/C,YAAY,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;YAChC,MAAM,EAAE,GAAG,YAAY,CAAC,QAAQ,EAAE,CAAC;YACnC,OAAO,QAAQ,CACb,eAAe,MAAM,YAAY,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CACtD,CAAC;QACJ,CAAC;QAED,KAAK,CAAC,WAAW,CACf,MAAc,EACd,OAAe,EACf,cAA0C,MAAM,EAChD,QAAkC,EAClC,OAAyB;YAEzB,OAAO,QAAQ,CAAc,eAAe,MAAM,WAAW,EAAE;gBAC7D,MAAM,EAAE,MAAM;gBACd,mEAAmE;gBACnE,sEAAsE;gBACtE,6DAA6D;gBAC7D,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;oBACnB,OAAO;oBACP,WAAW;oBACX,QAAQ;oBACR,GAAG,CAAC,OAAO,EAAE,gBAAgB,KAAK,SAAS;wBACzC,CAAC,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,gBAAgB,EAAE;wBAChD,CAAC,CAAC,EAAE,CAAC;iBACR,CAAC;aACH,CAAC,CAAC;QACL,CAAC;QAED,KAAK,CAAC,QAAQ,CAAC,MAAc,EAAE,SAAiB;YAC9C,OAAO,QAAQ,CAAO,eAAe,MAAM,aAAa,SAAS,OAAO,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC;QAC/F,CAAC;QAED,KAAK,CAAC,YAAY,CAAC,MAAc;YAC/B,OAAO,QAAQ,CAAyB,eAAe,MAAM,OAAO,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC;QAC3F,CAAC;QAED,KAAK,CAAC,WAAW;YACf,OAAO,QAAQ,CAAqB,iBAAiB,EAAE;gBACrD,MAAM,EAAE,MAAM;aACf,CAAC,CAAC;QACL,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ChatAttachmentUploadAdapter, ChatComposerAttachmentContext, ChatComposerConfirmedAttempt, ChatComposerSendResult, ChatSurfaceReplyQuote } from "./chat-surface.js";
|
|
2
|
+
export interface ChatAttachmentLabels {
|
|
3
|
+
uploading: string;
|
|
4
|
+
ready: string;
|
|
5
|
+
linking: string;
|
|
6
|
+
uploadFailed: string;
|
|
7
|
+
/** Accurate recovery copy: upload succeeded, message link did not. */
|
|
8
|
+
linkFailed: string;
|
|
9
|
+
retry: string;
|
|
10
|
+
remove: string;
|
|
11
|
+
tooManyFiles: string;
|
|
12
|
+
fileTooLarge: string;
|
|
13
|
+
uploadPaused: string;
|
|
14
|
+
sendFailed: string;
|
|
15
|
+
messageIdMissing: string;
|
|
16
|
+
}
|
|
17
|
+
export declare const CHAT_ATTACHMENT_DEFAULT_LABELS: ChatAttachmentLabels;
|
|
18
|
+
export interface ChatComposerProps {
|
|
19
|
+
/** Isolates drafts and attachment queues. Required when using `attachmentAdapter`. */
|
|
20
|
+
roomId?: string;
|
|
21
|
+
value?: string;
|
|
22
|
+
defaultValue?: string;
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
isSending?: boolean;
|
|
26
|
+
maxLength?: number;
|
|
27
|
+
attachmentsSlot?: React.ReactNode;
|
|
28
|
+
attachmentAdapter?: ChatAttachmentUploadAdapter;
|
|
29
|
+
attachmentLabels?: Partial<ChatAttachmentLabels>;
|
|
30
|
+
/** Localize attachment-only fallback content sent to the chat API. */
|
|
31
|
+
formatAttachmentOnlyContent?: (fileNames: string[]) => string;
|
|
32
|
+
/** Non-blocking transport or availability notice rendered above the composer. */
|
|
33
|
+
notice?: React.ReactNode;
|
|
34
|
+
/**
|
|
35
|
+
* CEL-1990 — when set, the composer shows which message is being answered
|
|
36
|
+
* and offers a way out. The parent owns the state: this component never
|
|
37
|
+
* clears it, because the send that consumes it is also the parent's.
|
|
38
|
+
*/
|
|
39
|
+
replyingTo?: ChatSurfaceReplyQuote | null;
|
|
40
|
+
onCancelReply?: () => void;
|
|
41
|
+
/** i18n. `{author}` is substituted. */
|
|
42
|
+
replyingToLabel?: string;
|
|
43
|
+
/** i18n. */
|
|
44
|
+
cancelReplyLabel?: string;
|
|
45
|
+
unknownAuthorLabel?: string;
|
|
46
|
+
unavailablePreviewLabel?: string;
|
|
47
|
+
attachLabel?: string;
|
|
48
|
+
/** Send-button label (visible + aria-label) at rest. Defaults to "Send". */
|
|
49
|
+
sendLabel?: string;
|
|
50
|
+
/** Send-button label (visible + aria-label) while a send is in flight. Defaults to "Sending". */
|
|
51
|
+
sendingLabel?: string;
|
|
52
|
+
/** Screen-reader-only label for the message textarea. Defaults to "Message". */
|
|
53
|
+
fieldLabel?: string;
|
|
54
|
+
onValueChange?: (value: string) => void;
|
|
55
|
+
/** REST-confirmed attempt-to-row mappings. Only these may authorize attachment links. */
|
|
56
|
+
confirmedAttempts?: readonly ChatComposerConfirmedAttempt[];
|
|
57
|
+
onAttemptConfirmed?: (attempt: ChatComposerConfirmedAttempt & {
|
|
58
|
+
replyToMessageId?: string;
|
|
59
|
+
}) => void;
|
|
60
|
+
onAttemptPendingChange?: (pending: boolean) => void;
|
|
61
|
+
onSend: (value: string, attachmentContext?: ChatComposerAttachmentContext) => void | ChatComposerSendResult | Promise<void | ChatComposerSendResult>;
|
|
62
|
+
onAttach?: () => void;
|
|
63
|
+
onTyping?: () => void;
|
|
64
|
+
className?: string;
|
|
65
|
+
}
|
|
66
|
+
export declare function ChatComposer({ roomId, value, defaultValue, placeholder, disabled, isSending, maxLength, attachmentsSlot, attachmentAdapter, attachmentLabels, formatAttachmentOnlyContent, notice, replyingTo, onCancelReply, replyingToLabel, cancelReplyLabel, unknownAuthorLabel, unavailablePreviewLabel, attachLabel, sendLabel, sendingLabel, fieldLabel, onValueChange, confirmedAttempts, onAttemptConfirmed, onAttemptPendingChange, onSend, onAttach, onTyping, className, }: ChatComposerProps): import("react/jsx-runtime").JSX.Element;
|
|
67
|
+
//# sourceMappingURL=chat-composer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chat-composer.d.ts","sourceRoot":"","sources":["../../src/chat/chat-composer.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
3
|
+
import { CircleAlert, CornerUpLeft, FileText, LoaderCircle, Paperclip, RotateCcw, Send, Trash2, X, } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/utils.js";
|
|
5
|
+
import { formatChatLabel } from "./chat-labels.js";
|
|
6
|
+
function createAttemptMessageId() {
|
|
7
|
+
if (globalThis.crypto?.randomUUID)
|
|
8
|
+
return globalThis.crypto.randomUUID();
|
|
9
|
+
const bytes = new Uint8Array(16);
|
|
10
|
+
if (globalThis.crypto?.getRandomValues)
|
|
11
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
12
|
+
else
|
|
13
|
+
for (let index = 0; index < bytes.length; index += 1)
|
|
14
|
+
bytes[index] = Math.random() * 256;
|
|
15
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
16
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
17
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
|
18
|
+
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
|
|
19
|
+
}
|
|
20
|
+
export const CHAT_ATTACHMENT_DEFAULT_LABELS = {
|
|
21
|
+
uploading: "Uploading",
|
|
22
|
+
ready: "Ready to send",
|
|
23
|
+
linking: "Sharing file",
|
|
24
|
+
uploadFailed: "Upload failed",
|
|
25
|
+
linkFailed: "File uploaded but was not shared",
|
|
26
|
+
retry: "Retry {filename}",
|
|
27
|
+
remove: "Remove {filename}",
|
|
28
|
+
tooManyFiles: "Too many files selected",
|
|
29
|
+
fileTooLarge: "{filename} exceeds the file size limit",
|
|
30
|
+
uploadPaused: "Upload paused when the conversation changed",
|
|
31
|
+
sendFailed: "Message could not be sent. Your text and files were kept.",
|
|
32
|
+
messageIdMissing: "Message sent, but the file was not shared. Remove it or contact support for help.",
|
|
33
|
+
};
|
|
34
|
+
function errorText(error, fallback) {
|
|
35
|
+
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
|
36
|
+
}
|
|
37
|
+
function defaultAttachmentOnlyContent(fileNames) {
|
|
38
|
+
const noun = fileNames.length === 1 ? "attachment" : "attachments";
|
|
39
|
+
return `${fileNames.length} ${noun}`;
|
|
40
|
+
}
|
|
41
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
42
|
+
function ChatComposerAttachmentTray({ attachments, labels, onRetry, onRemove, }) {
|
|
43
|
+
if (attachments.length === 0)
|
|
44
|
+
return null;
|
|
45
|
+
return (_jsx("div", { className: "flex min-w-0 flex-col gap-1.5 px-2 pt-2", children: attachments.map((attachment) => {
|
|
46
|
+
const stateLabel = attachment.status === "uploading"
|
|
47
|
+
? labels.uploading
|
|
48
|
+
: attachment.status === "ready"
|
|
49
|
+
? labels.ready
|
|
50
|
+
: attachment.status === "linking"
|
|
51
|
+
? labels.linking
|
|
52
|
+
: attachment.status === "link-failed"
|
|
53
|
+
? labels.linkFailed
|
|
54
|
+
: labels.uploadFailed;
|
|
55
|
+
const canRetry = attachment.status === "failed" ||
|
|
56
|
+
(attachment.status === "link-failed" && Boolean(attachment.linkedMessageId));
|
|
57
|
+
return (_jsxs("div", { "data-attachment-id": attachment.attachmentId ?? attachment.clientId, "data-attachment-status": attachment.status, className: "flex min-w-0 items-center gap-2 rounded-xl bg-muted/60 px-2 py-1.5 text-xs/5", children: [attachment.status === "uploading" || attachment.status === "linking" ? (_jsx(LoaderCircle, { className: "size-4 shrink-0 animate-spin motion-reduce:animate-none", "aria-hidden": "true" })) : attachment.status === "failed" || attachment.status === "link-failed" ? (_jsx(CircleAlert, { className: "size-4 shrink-0 text-[var(--tone-danger-fg)]", "aria-hidden": "true" })) : (_jsx(FileText, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": "true" })), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate font-medium", children: attachment.fileName }), _jsx("span", { className: "block text-muted-foreground", role: "status", children: attachment.errorMessage ?? stateLabel }), attachment.status === "uploading" && attachment.progress !== undefined ? (_jsx("progress", { className: "mt-1 h-1 w-full accent-primary", max: 100, value: Math.min(100, Math.max(0, attachment.progress)), "aria-label": `${labels.uploading}: ${attachment.fileName}` })) : null] }), canRetry ? (_jsx("button", { type: "button", onClick: () => onRetry(attachment.clientId), "aria-label": formatChatLabel(labels.retry, { filename: attachment.fileName }), className: "grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors duration-[var(--duration-micro)] hover:bg-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring", children: _jsx(RotateCcw, { className: "size-4", "aria-hidden": "true" }) })) : null, _jsx("button", { type: "button", onClick: () => onRemove(attachment.clientId), disabled: attachment.status === "linking", "aria-label": formatChatLabel(labels.remove, { filename: attachment.fileName }), className: "grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors duration-[var(--duration-micro)] hover:bg-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", children: _jsx(Trash2, { className: "size-4", "aria-hidden": "true" }) })] }, attachment.clientId));
|
|
58
|
+
}) }));
|
|
59
|
+
}
|
|
60
|
+
export function ChatComposer({ roomId, value, defaultValue = "", placeholder = "Write a message", disabled = false, isSending = false, maxLength, attachmentsSlot, attachmentAdapter, attachmentLabels, formatAttachmentOnlyContent = defaultAttachmentOnlyContent, notice, replyingTo, onCancelReply, replyingToLabel = "Replying to {author}", cancelReplyLabel = "Cancel reply", unknownAuthorLabel = "Unknown sender", unavailablePreviewLabel = "Message unavailable", attachLabel = "Attach file", sendLabel = "Send", sendingLabel = "Sending", fieldLabel = "Message", onValueChange, confirmedAttempts, onAttemptConfirmed, onAttemptPendingChange, onSend, onAttach, onTyping, className, }) {
|
|
61
|
+
const fieldId = useId();
|
|
62
|
+
const fileInputRef = useRef(null);
|
|
63
|
+
const roomKey = roomId ?? "__default-chat-room__";
|
|
64
|
+
const currentRoomRef = useRef(roomKey);
|
|
65
|
+
const queuesByRoomRef = useRef(new Map());
|
|
66
|
+
const attemptsByRoomRef = useRef(new Map());
|
|
67
|
+
const linkFlightsRef = useRef(new Map());
|
|
68
|
+
const deferredConfirmationsRef = useRef(new Map());
|
|
69
|
+
const draftsByRoomRef = useRef(new Map([[roomKey, defaultValue]]));
|
|
70
|
+
const uploadControllersRef = useRef(new Map());
|
|
71
|
+
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
72
|
+
const [attachments, setAttachments] = useState([]);
|
|
73
|
+
const [attachmentNotice, setAttachmentNotice] = useState("");
|
|
74
|
+
const [isAttachmentSubmitting, setIsAttachmentSubmitting] = useState(false);
|
|
75
|
+
const [activeAttemptMessageId, setActiveAttemptMessageId] = useState(null);
|
|
76
|
+
const latestTextRef = useRef(value ?? defaultValue);
|
|
77
|
+
const submissionSequenceRef = useRef(0);
|
|
78
|
+
const labels = { ...CHAT_ATTACHMENT_DEFAULT_LABELS, ...attachmentLabels };
|
|
79
|
+
const text = value ?? internalValue;
|
|
80
|
+
latestTextRef.current = text;
|
|
81
|
+
const remaining = typeof maxLength === "number" ? maxLength - text.length : undefined;
|
|
82
|
+
const readyAttachments = attachments.filter((attachment) => attachment.status === "ready");
|
|
83
|
+
const attachmentWorkInFlight = attachments.some((attachment) => attachment.status === "uploading" || attachment.status === "linking");
|
|
84
|
+
const attemptLocked = activeAttemptMessageId !== null;
|
|
85
|
+
const sending = isSending || isAttachmentSubmitting;
|
|
86
|
+
const canSend = (attemptLocked || text.trim().length > 0 || readyAttachments.length > 0) &&
|
|
87
|
+
!disabled &&
|
|
88
|
+
!sending &&
|
|
89
|
+
!attachmentWorkInFlight;
|
|
90
|
+
const mutateRoomQueue = useCallback((targetRoomKey, update) => {
|
|
91
|
+
const next = update(queuesByRoomRef.current.get(targetRoomKey) ?? []);
|
|
92
|
+
queuesByRoomRef.current.set(targetRoomKey, next);
|
|
93
|
+
if (currentRoomRef.current === targetRoomKey)
|
|
94
|
+
setAttachments(next);
|
|
95
|
+
}, []);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const previousRoomKey = currentRoomRef.current;
|
|
98
|
+
if (previousRoomKey === roomKey)
|
|
99
|
+
return;
|
|
100
|
+
for (const [clientId, active] of uploadControllersRef.current) {
|
|
101
|
+
if (active.roomKey !== previousRoomKey)
|
|
102
|
+
continue;
|
|
103
|
+
active.controller.abort();
|
|
104
|
+
uploadControllersRef.current.delete(clientId);
|
|
105
|
+
}
|
|
106
|
+
mutateRoomQueue(previousRoomKey, (rows) => rows.map((row) => row.status === "uploading"
|
|
107
|
+
? { ...row, status: "failed", progress: undefined, errorMessage: labels.uploadPaused }
|
|
108
|
+
: row));
|
|
109
|
+
currentRoomRef.current = roomKey;
|
|
110
|
+
submissionSequenceRef.current += 1;
|
|
111
|
+
setAttachments(queuesByRoomRef.current.get(roomKey) ?? []);
|
|
112
|
+
const nextAttemptMessageId = attemptsByRoomRef.current.get(roomKey)?.attemptMessageId ?? null;
|
|
113
|
+
setActiveAttemptMessageId(nextAttemptMessageId);
|
|
114
|
+
onAttemptPendingChange?.(nextAttemptMessageId !== null);
|
|
115
|
+
setAttachmentNotice("");
|
|
116
|
+
setIsAttachmentSubmitting(false);
|
|
117
|
+
if (value === undefined)
|
|
118
|
+
setInternalValue(draftsByRoomRef.current.get(roomKey) ?? "");
|
|
119
|
+
}, [labels.uploadPaused, mutateRoomQueue, onAttemptPendingChange, roomKey, value]);
|
|
120
|
+
useEffect(() => () => {
|
|
121
|
+
for (const active of uploadControllersRef.current.values())
|
|
122
|
+
active.controller.abort();
|
|
123
|
+
uploadControllersRef.current.clear();
|
|
124
|
+
}, []);
|
|
125
|
+
function updateText(next) {
|
|
126
|
+
if (typeof maxLength === "number" && next.length > maxLength)
|
|
127
|
+
return;
|
|
128
|
+
latestTextRef.current = next;
|
|
129
|
+
if (value === undefined) {
|
|
130
|
+
draftsByRoomRef.current.set(roomKey, next);
|
|
131
|
+
setInternalValue(next);
|
|
132
|
+
}
|
|
133
|
+
onValueChange?.(next);
|
|
134
|
+
onTyping?.();
|
|
135
|
+
}
|
|
136
|
+
async function startUpload(targetRoomKey, row) {
|
|
137
|
+
if (!attachmentAdapter)
|
|
138
|
+
return;
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
uploadControllersRef.current.set(row.clientId, { roomKey: targetRoomKey, controller });
|
|
141
|
+
mutateRoomQueue(targetRoomKey, (rows) => rows.map((candidate) => candidate.clientId === row.clientId
|
|
142
|
+
? { ...candidate, status: "uploading", progress: 0, errorMessage: undefined }
|
|
143
|
+
: candidate));
|
|
144
|
+
try {
|
|
145
|
+
const uploaded = await attachmentAdapter.upload(row.file, {
|
|
146
|
+
signal: controller.signal,
|
|
147
|
+
onProgress(progress) {
|
|
148
|
+
if (controller.signal.aborted)
|
|
149
|
+
return;
|
|
150
|
+
mutateRoomQueue(targetRoomKey, (rows) => rows.map((candidate) => candidate.clientId === row.clientId && candidate.status === "uploading"
|
|
151
|
+
? { ...candidate, progress }
|
|
152
|
+
: candidate));
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
if (controller.signal.aborted)
|
|
156
|
+
return;
|
|
157
|
+
mutateRoomQueue(targetRoomKey, (rows) => rows.map((candidate) => candidate.clientId === row.clientId
|
|
158
|
+
? {
|
|
159
|
+
...candidate,
|
|
160
|
+
id: uploaded.attachmentId,
|
|
161
|
+
attachmentId: uploaded.attachmentId,
|
|
162
|
+
fileName: uploaded.fileName,
|
|
163
|
+
mimeType: uploaded.mimeType,
|
|
164
|
+
sizeBytes: uploaded.sizeBytes,
|
|
165
|
+
status: "ready",
|
|
166
|
+
progress: 100,
|
|
167
|
+
errorMessage: undefined,
|
|
168
|
+
}
|
|
169
|
+
: candidate));
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (controller.signal.aborted)
|
|
173
|
+
return;
|
|
174
|
+
mutateRoomQueue(targetRoomKey, (rows) => rows.map((candidate) => candidate.clientId === row.clientId
|
|
175
|
+
? {
|
|
176
|
+
...candidate,
|
|
177
|
+
status: "failed",
|
|
178
|
+
progress: undefined,
|
|
179
|
+
errorMessage: errorText(error, labels.uploadFailed),
|
|
180
|
+
}
|
|
181
|
+
: candidate));
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
const active = uploadControllersRef.current.get(row.clientId);
|
|
185
|
+
if (active?.controller === controller)
|
|
186
|
+
uploadControllersRef.current.delete(row.clientId);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function addFiles(fileList) {
|
|
190
|
+
if (!attachmentAdapter)
|
|
191
|
+
return;
|
|
192
|
+
const files = Array.from(fileList);
|
|
193
|
+
const currentCount = queuesByRoomRef.current.get(roomKey)?.length ?? 0;
|
|
194
|
+
const available = Math.max(0, (attachmentAdapter.maxFiles ?? Infinity) - currentCount);
|
|
195
|
+
if (files.length > available)
|
|
196
|
+
setAttachmentNotice(labels.tooManyFiles);
|
|
197
|
+
const accepted = [];
|
|
198
|
+
for (const file of files.slice(0, available)) {
|
|
199
|
+
const validationError = (attachmentAdapter.maxBytes !== undefined && file.size > attachmentAdapter.maxBytes
|
|
200
|
+
? formatChatLabel(labels.fileTooLarge, { filename: file.name })
|
|
201
|
+
: null) ?? attachmentAdapter.validateFile?.(file);
|
|
202
|
+
if (validationError) {
|
|
203
|
+
setAttachmentNotice(validationError);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const clientId = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
|
|
207
|
+
accepted.push({
|
|
208
|
+
clientId,
|
|
209
|
+
id: clientId,
|
|
210
|
+
file,
|
|
211
|
+
fileName: file.name,
|
|
212
|
+
mimeType: file.type || undefined,
|
|
213
|
+
sizeBytes: file.size,
|
|
214
|
+
status: "uploading",
|
|
215
|
+
progress: 0,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (accepted.length === 0)
|
|
219
|
+
return;
|
|
220
|
+
setAttachmentNotice("");
|
|
221
|
+
mutateRoomQueue(roomKey, (rows) => [...rows, ...accepted]);
|
|
222
|
+
for (const row of accepted)
|
|
223
|
+
void startUpload(roomKey, row);
|
|
224
|
+
}
|
|
225
|
+
function removeAttachment(clientId) {
|
|
226
|
+
uploadControllersRef.current.get(clientId)?.controller.abort();
|
|
227
|
+
uploadControllersRef.current.delete(clientId);
|
|
228
|
+
const row = (queuesByRoomRef.current.get(roomKey) ?? []).find((candidate) => candidate.clientId === clientId);
|
|
229
|
+
if (row?.status === "linking")
|
|
230
|
+
return;
|
|
231
|
+
mutateRoomQueue(roomKey, (rows) => rows.filter((row) => row.clientId !== clientId));
|
|
232
|
+
if (!row?.attemptMessageId)
|
|
233
|
+
return;
|
|
234
|
+
const attempt = attemptsByRoomRef.current.get(roomKey);
|
|
235
|
+
if (!attempt || attempt.attemptMessageId !== row.attemptMessageId)
|
|
236
|
+
return;
|
|
237
|
+
const remainingIds = attempt.attachmentClientIds.filter((id) => id !== clientId);
|
|
238
|
+
if (remainingIds.length > 0) {
|
|
239
|
+
attemptsByRoomRef.current.set(roomKey, { ...attempt, attachmentClientIds: remainingIds });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// This only discards the local file/retry transaction. A request whose
|
|
243
|
+
// acknowledgement was lost may already have created its chat message.
|
|
244
|
+
attemptsByRoomRef.current.delete(roomKey);
|
|
245
|
+
deferredConfirmationsRef.current.delete(attempt.attemptMessageId);
|
|
246
|
+
if (currentRoomRef.current === roomKey) {
|
|
247
|
+
setActiveAttemptMessageId(null);
|
|
248
|
+
setAttachmentNotice("");
|
|
249
|
+
onAttemptPendingChange?.(false);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function retryAttachment(clientId) {
|
|
253
|
+
const row = (queuesByRoomRef.current.get(roomKey) ?? []).find((candidate) => candidate.clientId === clientId);
|
|
254
|
+
if (!row || !attachmentAdapter)
|
|
255
|
+
return;
|
|
256
|
+
if (row.status === "failed") {
|
|
257
|
+
await startUpload(roomKey, row);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (row.status !== "link-failed" || !row.attachmentId || !row.linkedMessageId)
|
|
261
|
+
return;
|
|
262
|
+
mutateRoomQueue(roomKey, (rows) => rows.map((candidate) => candidate.clientId === clientId
|
|
263
|
+
? { ...candidate, status: "linking", errorMessage: undefined }
|
|
264
|
+
: candidate));
|
|
265
|
+
try {
|
|
266
|
+
await attachmentAdapter.linkToMessage({
|
|
267
|
+
attachmentId: row.attachmentId,
|
|
268
|
+
messageId: row.linkedMessageId,
|
|
269
|
+
});
|
|
270
|
+
mutateRoomQueue(roomKey, (rows) => rows.filter((candidate) => candidate.clientId !== clientId));
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
mutateRoomQueue(roomKey, (rows) => rows.map((candidate) => candidate.clientId === clientId
|
|
274
|
+
? {
|
|
275
|
+
...candidate,
|
|
276
|
+
status: "link-failed",
|
|
277
|
+
errorMessage: error instanceof Error && error.message.trim()
|
|
278
|
+
? `${labels.linkFailed}: ${error.message}`
|
|
279
|
+
: labels.linkFailed,
|
|
280
|
+
}
|
|
281
|
+
: candidate));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const confirmAttempt = useCallback((confirmation) => {
|
|
285
|
+
const attempt = Array.from(attemptsByRoomRef.current.values()).find((candidate) => candidate.attemptMessageId === confirmation.attemptMessageId);
|
|
286
|
+
if (!attempt || !UUID_PATTERN.test(confirmation.messageId))
|
|
287
|
+
return Promise.resolve();
|
|
288
|
+
deferredConfirmationsRef.current.set(confirmation.attemptMessageId, confirmation);
|
|
289
|
+
// A completion from room A must not mutate room B. The attempt stays
|
|
290
|
+
// available and will finish if room A becomes active again while the
|
|
291
|
+
// authoritative confirmation mapping is still present.
|
|
292
|
+
if (currentRoomRef.current !== attempt.roomKey)
|
|
293
|
+
return Promise.resolve();
|
|
294
|
+
const existingFlight = linkFlightsRef.current.get(attempt.attemptMessageId);
|
|
295
|
+
if (existingFlight)
|
|
296
|
+
return existingFlight;
|
|
297
|
+
const flight = (async () => {
|
|
298
|
+
deferredConfirmationsRef.current.delete(attempt.attemptMessageId);
|
|
299
|
+
attemptsByRoomRef.current.delete(attempt.roomKey);
|
|
300
|
+
setActiveAttemptMessageId(null);
|
|
301
|
+
onAttemptPendingChange?.(false);
|
|
302
|
+
setIsAttachmentSubmitting(false);
|
|
303
|
+
setAttachmentNotice("");
|
|
304
|
+
if (latestTextRef.current === attempt.submittedText) {
|
|
305
|
+
latestTextRef.current = "";
|
|
306
|
+
if (value === undefined) {
|
|
307
|
+
draftsByRoomRef.current.set(attempt.roomKey, "");
|
|
308
|
+
setInternalValue("");
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
onValueChange?.("");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
onAttemptConfirmed?.({
|
|
315
|
+
attemptMessageId: attempt.attemptMessageId,
|
|
316
|
+
messageId: confirmation.messageId,
|
|
317
|
+
...(attempt.replyToMessageId ? { replyToMessageId: attempt.replyToMessageId } : {}),
|
|
318
|
+
});
|
|
319
|
+
mutateRoomQueue(attempt.roomKey, (rows) => rows.map((candidate) => attempt.attachmentClientIds.includes(candidate.clientId)
|
|
320
|
+
? {
|
|
321
|
+
...candidate,
|
|
322
|
+
status: "linking",
|
|
323
|
+
linkedMessageId: confirmation.messageId,
|
|
324
|
+
errorMessage: undefined,
|
|
325
|
+
}
|
|
326
|
+
: candidate));
|
|
327
|
+
await Promise.all(attempt.attachmentClientIds.map(async (clientId) => {
|
|
328
|
+
const attachment = (queuesByRoomRef.current.get(attempt.roomKey) ?? []).find((candidate) => candidate.clientId === clientId);
|
|
329
|
+
if (!attachment?.attachmentId)
|
|
330
|
+
return;
|
|
331
|
+
try {
|
|
332
|
+
await attempt.adapter.linkToMessage({
|
|
333
|
+
attachmentId: attachment.attachmentId,
|
|
334
|
+
messageId: confirmation.messageId,
|
|
335
|
+
});
|
|
336
|
+
mutateRoomQueue(attempt.roomKey, (rows) => rows.filter((candidate) => candidate.clientId !== clientId));
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
mutateRoomQueue(attempt.roomKey, (rows) => rows.map((candidate) => candidate.clientId === clientId
|
|
340
|
+
? {
|
|
341
|
+
...candidate,
|
|
342
|
+
status: "link-failed",
|
|
343
|
+
linkedMessageId: confirmation.messageId,
|
|
344
|
+
errorMessage: error instanceof Error && error.message.trim()
|
|
345
|
+
? `${labels.linkFailed}: ${error.message}`
|
|
346
|
+
: labels.linkFailed,
|
|
347
|
+
}
|
|
348
|
+
: candidate));
|
|
349
|
+
}
|
|
350
|
+
}));
|
|
351
|
+
})().finally(() => {
|
|
352
|
+
linkFlightsRef.current.delete(attempt.attemptMessageId);
|
|
353
|
+
});
|
|
354
|
+
linkFlightsRef.current.set(attempt.attemptMessageId, flight);
|
|
355
|
+
return flight;
|
|
356
|
+
}, [
|
|
357
|
+
labels.linkFailed,
|
|
358
|
+
mutateRoomQueue,
|
|
359
|
+
onAttemptConfirmed,
|
|
360
|
+
onAttemptPendingChange,
|
|
361
|
+
onValueChange,
|
|
362
|
+
value,
|
|
363
|
+
]);
|
|
364
|
+
useEffect(() => {
|
|
365
|
+
const confirmations = [
|
|
366
|
+
...(confirmedAttempts ?? []),
|
|
367
|
+
...deferredConfirmationsRef.current.values(),
|
|
368
|
+
];
|
|
369
|
+
for (const confirmation of confirmations) {
|
|
370
|
+
void confirmAttempt(confirmation);
|
|
371
|
+
}
|
|
372
|
+
}, [activeAttemptMessageId, confirmAttempt, confirmedAttempts]);
|
|
373
|
+
async function sendCurrent() {
|
|
374
|
+
const targetRoomKey = roomKey;
|
|
375
|
+
const submittedText = text;
|
|
376
|
+
const submissionId = ++submissionSequenceRef.current;
|
|
377
|
+
const trimmed = text.trim();
|
|
378
|
+
const ready = (queuesByRoomRef.current.get(targetRoomKey) ?? []).filter((attachment) => attachment.status === "ready" && attachment.attachmentId);
|
|
379
|
+
if ((!trimmed && ready.length === 0) || disabled || sending || attachmentWorkInFlight)
|
|
380
|
+
return;
|
|
381
|
+
// Text-only sends retain the established behavior. Attachment sends use
|
|
382
|
+
// the explicit room-scoped attempt below so ambiguous responses are safe
|
|
383
|
+
// to retry without generating another chat UUID.
|
|
384
|
+
if (ready.length === 0 && !attemptsByRoomRef.current.has(targetRoomKey)) {
|
|
385
|
+
try {
|
|
386
|
+
await onSend(trimmed);
|
|
387
|
+
if (currentRoomRef.current === targetRoomKey && latestTextRef.current === submittedText) {
|
|
388
|
+
latestTextRef.current = "";
|
|
389
|
+
if (value === undefined) {
|
|
390
|
+
draftsByRoomRef.current.set(targetRoomKey, "");
|
|
391
|
+
setInternalValue("");
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
onValueChange?.("");
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// The text remains in the controlled/uncontrolled draft for retry.
|
|
400
|
+
}
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
let attempt = attemptsByRoomRef.current.get(targetRoomKey);
|
|
404
|
+
if (!attempt) {
|
|
405
|
+
if (!attachmentAdapter || ready.length === 0)
|
|
406
|
+
return;
|
|
407
|
+
const generatedAttachmentContent = formatAttachmentOnlyContent(ready.map((attachment) => attachment.fileName)).trim();
|
|
408
|
+
const contentLimit = maxLength ?? 10_000;
|
|
409
|
+
const content = trimmed ||
|
|
410
|
+
(generatedAttachmentContent ||
|
|
411
|
+
defaultAttachmentOnlyContent(ready.map((row) => row.fileName))).slice(0, contentLimit);
|
|
412
|
+
const attemptMessageId = createAttemptMessageId();
|
|
413
|
+
attempt = {
|
|
414
|
+
attemptMessageId,
|
|
415
|
+
roomKey: targetRoomKey,
|
|
416
|
+
content,
|
|
417
|
+
submittedText,
|
|
418
|
+
attachmentClientIds: ready.map((attachment) => attachment.clientId),
|
|
419
|
+
isAttachmentOnly: trimmed.length === 0,
|
|
420
|
+
...(replyingTo?.messageId ? { replyToMessageId: replyingTo.messageId } : {}),
|
|
421
|
+
adapter: attachmentAdapter,
|
|
422
|
+
};
|
|
423
|
+
attemptsByRoomRef.current.set(targetRoomKey, attempt);
|
|
424
|
+
setActiveAttemptMessageId(attemptMessageId);
|
|
425
|
+
onAttemptPendingChange?.(true);
|
|
426
|
+
mutateRoomQueue(targetRoomKey, (rows) => rows.map((candidate) => attempt.attachmentClientIds.includes(candidate.clientId)
|
|
427
|
+
? { ...candidate, attemptMessageId }
|
|
428
|
+
: candidate));
|
|
429
|
+
}
|
|
430
|
+
setAttachmentNotice("");
|
|
431
|
+
setIsAttachmentSubmitting(true);
|
|
432
|
+
try {
|
|
433
|
+
const result = await onSend(attempt.content, {
|
|
434
|
+
attemptMessageId: attempt.attemptMessageId,
|
|
435
|
+
attachmentClientIds: attempt.attachmentClientIds,
|
|
436
|
+
isAttachmentOnly: attempt.isAttachmentOnly,
|
|
437
|
+
...(attempt.replyToMessageId ? { replyToMessageId: attempt.replyToMessageId } : {}),
|
|
438
|
+
});
|
|
439
|
+
const messageId = result && typeof result === "object" ? result.messageId : undefined;
|
|
440
|
+
if (typeof messageId !== "string" || !UUID_PATTERN.test(messageId)) {
|
|
441
|
+
if (currentRoomRef.current === targetRoomKey) {
|
|
442
|
+
setAttachmentNotice(labels.messageIdMissing);
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
await confirmAttempt({ attemptMessageId: attempt.attemptMessageId, messageId });
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
if (currentRoomRef.current === targetRoomKey) {
|
|
450
|
+
setAttachmentNotice(labels.sendFailed);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
finally {
|
|
454
|
+
if (currentRoomRef.current === targetRoomKey &&
|
|
455
|
+
submissionSequenceRef.current === submissionId) {
|
|
456
|
+
setIsAttachmentSubmitting(false);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function submit(event) {
|
|
461
|
+
event.preventDefault();
|
|
462
|
+
void sendCurrent();
|
|
463
|
+
}
|
|
464
|
+
return (_jsxs(_Fragment, { children: [notice ? (_jsx("p", { "data-testid": "composer-live-updates-notice", role: "status", className: "px-1 pb-1 text-xs/5 text-muted-foreground", children: notice })) : null, replyingTo ? (_jsxs("div", { "data-testid": "composer-replying-to",
|
|
465
|
+
// `status`, not `alert`: entering reply mode is a state change the
|
|
466
|
+
// user just caused, not something that interrupts them.
|
|
467
|
+
role: "status", className: "mb-1 flex items-center gap-2 rounded-xl bg-muted/60 px-3 py-1.5 text-xs/5", children: [_jsx(CornerUpLeft, { className: "size-3.5 shrink-0 text-muted-foreground", "aria-hidden": "true" }), _jsxs("span", { className: "min-w-0 flex-1 truncate", children: [_jsx("span", { className: "font-semibold", children: formatChatLabel(replyingToLabel, {
|
|
468
|
+
author: replyingTo.authorName?.trim() || unknownAuthorLabel,
|
|
469
|
+
}) }), _jsxs("span", { className: "text-muted-foreground", children: [" ", replyingTo.preview?.trim() || unavailablePreviewLabel] })] }), onCancelReply ? (_jsx("button", { type: "button", onClick: onCancelReply, disabled: attemptLocked, "aria-label": cancelReplyLabel, className: cn("grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground", "transition-colors duration-[var(--duration-micro)] hover:text-foreground", "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"), children: _jsx(X, { className: "size-3.5", "aria-hidden": "true" }) })) : null] })) : null, _jsxs("form", { onSubmit: submit, className: cn("@container min-w-0 rounded-[1.65rem] bg-card p-1 ring-1 ring-border/60", "transition-[box-shadow,border-color,background-color] duration-[var(--duration-standard)] ease-[var(--ease-emphasized-strong)]", "focus-within:ring-primary/35 focus-within:shadow-[var(--shadow-soft)]", className), children: [_jsx(ChatComposerAttachmentTray, { attachments: attachments, labels: labels, onRetry: (clientId) => void retryAttachment(clientId), onRemove: removeAttachment }), attachmentNotice ? (_jsx("p", { className: "px-3 pt-1 text-xs/5 text-[var(--tone-danger-fg)]", role: "status", children: attachmentNotice })) : null, _jsxs("div", { className: "flex min-w-0 flex-wrap items-end gap-2 rounded-[calc(1.65rem-0.25rem)] bg-background px-1 py-2 @xs:flex-nowrap @xs:px-3", children: [attachmentsSlot ? (_jsx("div", { className: "min-w-0 max-w-full pb-1", children: attachmentsSlot })) : attachmentAdapter || onAttach ? (_jsx("button", { type: "button", "data-chat-attachment-trigger": true, "aria-label": attachLabel, disabled: disabled || attemptLocked, onClick: () => (attachmentAdapter ? fileInputRef.current?.click() : onAttach?.()), className: "grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground transition-[background-color,color,transform] duration-[var(--duration-standard)] ease-[var(--ease-emphasized-strong)] hover:-translate-y-px hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none motion-reduce:hover:translate-y-0", children: _jsx(Paperclip, { className: "size-4" }) })) : null, attachmentAdapter ? (_jsx("input", { ref: fileInputRef, type: "file", "data-chat-attachment-input": true, className: "sr-only", tabIndex: -1, multiple: attachmentAdapter.maxFiles !== 1, accept: attachmentAdapter.accept, disabled: disabled || attemptLocked, onChange: (event) => {
|
|
470
|
+
if (event.currentTarget.files)
|
|
471
|
+
addFiles(event.currentTarget.files);
|
|
472
|
+
event.currentTarget.value = "";
|
|
473
|
+
} })) : null, _jsx("label", { htmlFor: fieldId, className: "sr-only", children: fieldLabel }), _jsx("textarea", { id: fieldId, value: text, maxLength: maxLength, disabled: disabled || attemptLocked, placeholder: placeholder, rows: 1, onChange: (event) => updateText(event.target.value), onKeyDown: (event) => {
|
|
474
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
475
|
+
if (event.nativeEvent.isComposing)
|
|
476
|
+
return;
|
|
477
|
+
event.preventDefault();
|
|
478
|
+
void sendCurrent();
|
|
479
|
+
}
|
|
480
|
+
}, className: cn("order-first max-h-32 min-h-9 min-w-0 basis-full resize-none bg-transparent px-1 py-2 text-sm/6 text-foreground @xs:order-none @xs:basis-auto @xs:flex-1", "placeholder:text-muted-foreground outline-hidden disabled:cursor-not-allowed disabled:opacity-60") }), remaining !== undefined ? (_jsx("span", { className: "hidden pb-2 text-xs/4 tabular-nums text-muted-foreground sm:inline", children: remaining })) : null, _jsxs("button", { type: "submit", "aria-label": sending ? sendingLabel : sendLabel, disabled: !canSend, className: cn("group/send inline-flex shrink-0 items-center gap-2 rounded-full bg-primary px-1 py-1.5 @xs:pl-3.5 @xs:pr-1.5", "text-xs/5 font-semibold text-primary-foreground shadow-[var(--shadow-glow)]", "transition-[transform,box-shadow,opacity] duration-[var(--duration-standard)] ease-[var(--ease-emphasized-strong)]", "hover:-translate-y-px active:scale-[0.98] disabled:pointer-events-none disabled:opacity-45", "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", "motion-reduce:transition-none motion-reduce:hover:translate-y-0 motion-reduce:active:scale-100"), children: [_jsx("span", { className: "hidden @xs:inline", children: sending ? sendingLabel : sendLabel }), _jsx("span", { className: "grid size-7 place-items-center rounded-full bg-primary-foreground/15 ring-1 ring-primary-foreground/20 transition-transform duration-[var(--duration-standard)] ease-[var(--ease-emphasized-strong)] group-hover/send:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover/send:translate-x-0", children: _jsx(Send, { className: "size-3.5" }) })] })] })] })] }));
|
|
481
|
+
}
|
|
482
|
+
//# sourceMappingURL=chat-composer.js.map
|