Files
kandev/apps/web/components/task/chat/chat-input-toolbar-primitives.tsx
T

296 lines
9.0 KiB
TypeScript

"use client";
import { useCallback } from "react";
import {
IconArrowUp,
IconFileTextSpark,
IconPaperclip,
IconPlayerPauseFilled,
} from "@tabler/icons-react";
import { GridSpinner } from "@/components/grid-spinner";
import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
import { SHORTCUTS } from "@/lib/keyboard/constants";
import { formatShortcut } from "@/lib/keyboard/utils";
import { cn } from "@/lib/utils";
import { ChatSubmitPluginDecoration } from "./chat-submit-plugin-decoration";
import type { PluginPresentation } from "@/lib/plugins/types";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
import { useComposerActivity } from "./composer-disclosure";
type SubmitButtonProps = {
isAgentBusy: boolean;
canCancelAgent?: boolean;
sessionId: string | null;
/** Task context for the `chat-submit-decoration` plugin slot. */
taskId: string | null;
taskTitle?: string;
hasContent: boolean;
isDisabled: boolean;
submitDisabledReason?: string;
isSending: boolean;
planModeEnabled: boolean;
onCancel: () => void | Promise<void>;
onSubmit: () => void;
submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS];
presentation?: PluginPresentation;
};
type SendSubmitButtonProps = Pick<
SubmitButtonProps,
| "isAgentBusy"
| "sessionId"
| "taskId"
| "taskTitle"
| "presentation"
| "isDisabled"
| "isSending"
| "planModeEnabled"
| "onSubmit"
| "submitShortcut"
> & {
tooltipDescription?: string;
};
function submitTooltipDescription(
isAgentBusy: boolean,
planModeEnabled: boolean,
submitDisabledReason?: string,
) {
if (submitDisabledReason) return submitDisabledReason;
if (isAgentBusy) return t("task:submitTooltipQueueMessage");
if (planModeEnabled) return t("task:submitTooltipRequestPlanChanges");
return undefined;
}
function SendSubmitButton({
isAgentBusy,
sessionId,
taskId,
taskTitle,
isDisabled,
isSending,
planModeEnabled,
onSubmit,
submitShortcut,
presentation = "desktop",
tooltipDescription,
}: SendSubmitButtonProps) {
const { t } = useTranslation();
return (
<KeyboardShortcutTooltip
shortcut={submitShortcut}
description={tooltipDescription}
enabled={!isDisabled || !!tooltipDescription}
>
<span
className="relative inline-flex"
tabIndex={isDisabled && !!tooltipDescription ? 0 : undefined}
aria-label={isDisabled ? (tooltipDescription ?? t("task:submitUnavailable")) : undefined}
>
<Button
type="button"
variant="default"
size="icon"
className={cn(
presentation === "mobile"
? "min-h-11 min-w-11 rounded-full cursor-pointer"
: "h-7 w-7 rounded-full cursor-pointer",
planModeEnabled && "bg-violet-600 hover:bg-violet-500",
)}
disabled={isDisabled}
onMouseDown={(e) => e.preventDefault()}
onClick={onSubmit}
data-testid="submit-message-button"
>
{isSending && <GridSpinner className="text-primary-foreground" />}
{!isSending && planModeEnabled && <IconFileTextSpark className="h-4 w-4" />}
{!isSending && !planModeEnabled && <IconArrowUp className="h-4 w-4" />}
</Button>
<ChatSubmitPluginDecoration
sessionId={sessionId}
taskId={taskId}
taskTitle={taskTitle}
presentation={presentation}
isSending={isSending}
isAgentBusy={isAgentBusy}
isDisabled={isDisabled}
planModeEnabled={planModeEnabled}
/>
</span>
</KeyboardShortcutTooltip>
);
}
export function SubmitButton({
isAgentBusy,
canCancelAgent = isAgentBusy,
sessionId,
taskId,
taskTitle,
hasContent,
isDisabled,
submitDisabledReason,
isSending,
planModeEnabled,
onCancel,
onSubmit,
submitShortcut,
presentation = "desktop",
}: SubmitButtonProps) {
const { t } = useTranslation();
const showSendButton = !isAgentBusy || hasContent;
const storeApi = useAppStoreApi();
const isCancelling = useAppStore((state) => {
if (!sessionId) return false;
return (
state.taskSessions.items[sessionId]?.cancellation_pending === true ||
state.chatInput.cancellingBySessionId[sessionId] === true
);
});
useComposerActivity({ busy: isCancelling });
const tooltipDescription = submitTooltipDescription(
isAgentBusy,
planModeEnabled,
submitDisabledReason,
);
const handleCancelClick = useCallback(async () => {
if (!sessionId) return;
if (storeApi.getState().chatInput.cancellingBySessionId[sessionId]) return;
storeApi.getState().setCancelTurnPending(sessionId, true);
try {
await onCancel();
} catch (error) {
console.error("Failed to cancel agent turn:", error);
} finally {
storeApi.getState().setCancelTurnPending(sessionId, false);
}
}, [onCancel, sessionId, storeApi]);
return (
<div className="flex items-center gap-1">
{canCancelAgent && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="secondary"
size="icon"
className={cn(
presentation === "mobile" ? "min-h-11 min-w-11" : "h-7 w-7",
"rounded-full cursor-pointer bg-destructive/10 text-destructive hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-70",
)}
onClick={handleCancelClick}
disabled={isCancelling}
data-testid="cancel-agent-button"
>
{isCancelling ? (
<GridSpinner className="text-destructive" />
) : (
<IconPlayerPauseFilled className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isCancelling ? t("task:cancelling") : t("task:cancelAgent")}
</TooltipContent>
</Tooltip>
)}
{showSendButton && (
<SendSubmitButton
isAgentBusy={isAgentBusy}
sessionId={sessionId}
taskId={taskId}
taskTitle={taskTitle}
isDisabled={isDisabled}
isSending={isSending}
planModeEnabled={planModeEnabled}
onSubmit={onSubmit}
submitShortcut={submitShortcut}
presentation={presentation}
tooltipDescription={tooltipDescription}
/>
)}
</div>
);
}
export function PlanToggleButton({
planModeEnabled,
planModeAvailable,
onPlanModeChange,
presentation = "desktop",
}: {
planModeEnabled: boolean;
planModeAvailable: boolean;
onPlanModeChange: (enabled: boolean) => void;
presentation?: "desktop" | "mobile";
}) {
const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
const planModeShortcutLabel = formatShortcut(getShortcut("TOGGLE_PLAN_MODE", keyboardShortcuts));
const tooltip = planModeAvailable
? `Toggle plan mode (${planModeShortcutLabel}) - Agent collaborates on the plan without implementing changes`
: `Toggle plan layout (${planModeShortcutLabel}) - View and edit the plan (agent cannot read/write it without MCP)`;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
data-testid="plan-mode-toggle-button"
data-plan-available={planModeAvailable}
data-plan-enabled={planModeEnabled}
className={cn(
presentation === "mobile"
? "min-h-11 min-w-11 gap-1.5 px-2 hover:bg-muted/40 cursor-pointer"
: "h-7 gap-1.5 px-2 hover:bg-muted/40 cursor-pointer",
planModeEnabled && planModeAvailable && "bg-violet-500/15 text-violet-400",
)}
onClick={() => onPlanModeChange(!planModeEnabled)}
>
<IconFileTextSpark className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{tooltip}</TooltipContent>
</Tooltip>
);
}
export function AttachFilesButton({
onClick,
presentation = "desktop",
}: {
onClick: () => void;
presentation?: "desktop" | "mobile";
}) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
presentation === "mobile"
? "min-h-11 min-w-11 gap-1.5 px-2 cursor-pointer hover:bg-muted/40"
: "h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40",
)}
onClick={onClick}
data-testid="chat-attachments-button"
>
<IconPaperclip className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("task:attachFiles")}</TooltipContent>
</Tooltip>
);
}