'use client';
import { useMemo } from 'react';
import { ArrowUp, Paperclip, Square } from 'lucide-react';
import type { UseChatComposerReturn } from '../hooks/useChatComposer';
import type { ComposerAction } from './types';
/** `order` values reserved for built-in actions so host extras land between. */
const ORDER = {
attach: 100,
hostStart: 200,
hostEnd: 200,
mic: 800,
send: 900,
} as const;
export interface UseComposerActionsParams {
composer: UseChatComposerReturn;
isStreaming: boolean;
isDisabled: boolean;
/** Built-in attach action wiring. */
showAttachmentButton?: boolean;
onPickFiles?: () => void;
attachLabel?: string;
/** Host-supplied declarative clusters. */
actionsStart?: ComposerAction[];
actionsEnd?: ComposerAction[];
/** Send / stop wiring. */
onSend: () => void;
onCancel?: () => void;
sendLabel?: string;
stopLabel?: string;
/** Telegram-style mic↔send swap. When true (default), mic and send never
* show together — mic when the draft is empty, send once there is text. */
micSendSwap?: boolean;
}
export interface ComposerActionClusters {
actionsStart: ComposerAction[];
actionsEnd: ComposerAction[];
}
/**
* Merges built-in send/stop/attach descriptors with host-provided arrays,
* applies `hideWhen` against live composer state, resolves the mic↔send
* swap, and sorts each cluster by `order`.
*/
export function useComposerActions(params: UseComposerActionsParams): ComposerActionClusters {
const {
composer,
isStreaming,
isDisabled,
showAttachmentButton,
onPickFiles,
attachLabel = 'Attach files',
actionsStart,
actionsEnd,
onSend,
onCancel,
sendLabel = 'Send',
stopLabel = 'Stop',
micSendSwap = true,
} = params;
const hasText = composer.canSubmit;
const canSubmit = composer.canSubmit;
return useMemo(() => {
const start: ComposerAction[] = [];
const end: ComposerAction[] = [];
if (showAttachmentButton) {
start.push({
id: 'attach',
icon: ,
label: attachLabel,
onClick: () => onPickFiles?.(),
disabled: isDisabled,
variant: 'ghost',
order: ORDER.attach,
});
}
for (const a of actionsStart ?? []) {
start.push({ order: ORDER.hostStart, ...a });
}
for (const a of actionsEnd ?? []) {
end.push({ order: ORDER.hostEnd, ...a });
}
// Send / stop. While streaming → stop; otherwise → send. Both are
// round, theme-inverting buttons (ChatGPT/Gemini style).
if (isStreaming) {
end.push({
id: 'stop',
icon: ,
label: stopLabel,
onClick: () => onCancel?.(),
variant: 'send',
order: ORDER.send,
});
} else {
end.push({
id: 'send',
icon: ,
label: sendLabel,
onClick: onSend,
disabled: !canSubmit,
variant: 'send',
order: ORDER.send,
});
}
// Apply `hideWhen` against the live composer state.
const visible = (a: ComposerAction): boolean => {
switch (a.hideWhen) {
case 'streaming':
return !isStreaming;
case 'empty':
return hasText;
case 'hasText':
return !hasText;
case 'disabled':
return !isDisabled;
default:
return true;
}
};
let filteredEnd = end.filter(visible);
const filteredStart = start.filter(visible);
// Mic↔send swap: when there is text and a mic action exists, drop the
// mic so only send shows. The host marks its mic with `id: 'mic'`
// (or `hideWhen: 'hasText'`); this is a safety net for the common id.
if (micSendSwap && hasText) {
filteredEnd = filteredEnd.filter((a) => a.id !== 'mic');
}
const byOrder = (a: ComposerAction, b: ComposerAction) =>
(a.order ?? 0) - (b.order ?? 0);
return {
actionsStart: filteredStart.sort(byOrder),
actionsEnd: filteredEnd.sort(byOrder),
};
}, [
showAttachmentButton,
attachLabel,
onPickFiles,
isDisabled,
actionsStart,
actionsEnd,
isStreaming,
stopLabel,
onCancel,
sendLabel,
onSend,
canSubmit,
hasText,
micSendSwap,
]);
}