| 1 | import { memo, type MouseEventHandler, type PointerEventHandler } from "react"; |
| 2 | import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, Minimize2Icon, PlayIcon } from "lucide-react"; |
| 3 | import { CornerUpRight, ListPlus } from "lucide"; |
| 4 | import { MorphIcon } from "~/components/MorphIcon"; |
| 5 | import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; |
| 6 | import { formatContextWindowTokens } from "~/lib/contextWindow"; |
| 7 | import { cn } from "~/lib/utils"; |
| 8 | import { useShortcutModifierState } from "../../shortcutModifierState"; |
| 9 | import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; |
| 10 | import { Button } from "../ui/button"; |
| 11 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; |
| 12 | import { Spinner } from "../ui/spinner"; |
| 13 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 14 | import { composerFloatingLayerProps } from "./composerEventScope"; |
| 15 | import { |
| 16 | alternateComposerDispatchAction, |
| 17 | resolveComposerDispatchMode, |
| 18 | } from "@t3tools/client-runtime/state/composer-dispatch"; |
| 19 | |
| 20 | interface PendingActionState { |
| 21 | questionIndex: number; |
| 22 | isLastQuestion: boolean; |
| 23 | canAdvance: boolean; |
| 24 | isResponding: boolean; |
| 25 | isComplete: boolean; |
| 26 | } |
| 27 | |
| 28 | interface ComposerPrimaryActionsProps { |
| 29 | compact: boolean; |
| 30 | canOperateThread: boolean; |
| 31 | pendingAction: PendingActionState | null; |
| 32 | /** The turn is running: sending steers or queues instead of starting a turn. */ |
| 33 | isRunning: boolean; |
| 34 | /** Stop can reach a run, including one still preparing or starting. */ |
| 35 | canInterrupt: boolean; |
| 36 | followUpBehavior?: "queue" | "steer"; |
| 37 | alternateShortcutLabel?: string | null; |
| 38 | showPlanFollowUpPrompt: boolean; |
| 39 | promptHasText: boolean; |
| 40 | isSendBusy: boolean; |
| 41 | sendDisabledReason: string | null; |
| 42 | isConnecting: boolean; |
| 43 | isEnvironmentUnavailable: boolean; |
| 44 | isPreparingWorktree: boolean; |
| 45 | hasSendableContent: boolean; |
| 46 | canResume?: boolean; |
| 47 | preserveComposerFocusOnPointerDown?: boolean; |
| 48 | isEditingQueuedMessage?: boolean; |
| 49 | onSubmitMessage?: MouseEventHandler<HTMLButtonElement>; |
| 50 | onResume?: () => void; |
| 51 | onPreviousPendingQuestion: () => void; |
| 52 | onInterrupt: () => void; |
| 53 | onImplementPlanInNewThread: () => void; |
| 54 | /** Tokens a stale session would re-read. When set, a Compact chip shows the count and Enter compacts first. */ |
| 55 | compactBeforeSendTokens?: number | null; |
| 56 | /** The Compact chip is turned off, so the next send keeps full history. */ |
| 57 | keepFullHistory?: boolean; |
| 58 | onToggleKeepFullHistory?: () => void; |
| 59 | } |
| 60 | |
| 61 | const formatPendingPrimaryActionLabel = (input: { |
| 62 | compact: boolean; |
| 63 | isLastQuestion: boolean; |
| 64 | isResponding: boolean; |
| 65 | questionIndex: number; |
| 66 | }) => { |
| 67 | if (input.isResponding) { |
| 68 | return "Submitting..."; |
| 69 | } |
| 70 | if (input.compact) { |
| 71 | return input.isLastQuestion ? "Submit" : "Next"; |
| 72 | } |
| 73 | if (!input.isLastQuestion) { |
| 74 | return "Next question"; |
| 75 | } |
| 76 | return input.questionIndex > 0 ? "Submit answers" : "Submit answer"; |
| 77 | }; |
| 78 | |
| 79 | // The composer's labeled primary actions (Submit, Refine, Implement) share the send button's |
| 80 | // message-action pill, so they are composer-owned buttons rather than restyled Buttons. |
| 81 | const messageActionPillClassName = |
| 82 | "inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full bg-message-action font-medium text-base text-message-action-foreground shadow-xs shadow-message-action/24 outline-none hover:bg-message-action-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 disabled:shadow-none sm:text-sm"; |
| 83 | |
| 84 | const preventPointerFocus: PointerEventHandler<HTMLElement> = (event) => { |
| 85 | event.preventDefault(); |
| 86 | }; |
| 87 | |
| 88 | export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 89 | compact, |
| 90 | canOperateThread, |
| 91 | pendingAction, |
| 92 | isRunning, |
| 93 | canInterrupt, |
| 94 | followUpBehavior = "steer", |
| 95 | alternateShortcutLabel = null, |
| 96 | showPlanFollowUpPrompt, |
| 97 | promptHasText, |
| 98 | isSendBusy, |
| 99 | sendDisabledReason, |
| 100 | isConnecting, |
| 101 | isEnvironmentUnavailable, |
| 102 | isPreparingWorktree, |
| 103 | hasSendableContent, |
| 104 | canResume = false, |
| 105 | preserveComposerFocusOnPointerDown = false, |
| 106 | isEditingQueuedMessage = false, |
| 107 | onSubmitMessage, |
| 108 | onResume, |
| 109 | onPreviousPendingQuestion, |
| 110 | onInterrupt, |
| 111 | onImplementPlanInNewThread, |
| 112 | compactBeforeSendTokens = null, |
| 113 | keepFullHistory = false, |
| 114 | onToggleKeepFullHistory, |
| 115 | }: ComposerPrimaryActionsProps) { |
| 116 | const pointerFocusProps = preserveComposerFocusOnPointerDown |
| 117 | ? { onPointerDown: preventPointerFocus } |
| 118 | : undefined; |
| 119 | const environmentIdentificationMode = useEnvironmentIdentificationMode(); |
| 120 | const shortcutModifiers = useShortcutModifierState(); |
| 121 | const isQueuing = |
| 122 | !isEditingQueuedMessage && |
| 123 | resolveComposerDispatchMode({ |
| 124 | running: isRunning, |
| 125 | activeTurnDefault: followUpBehavior, |
| 126 | alternateModifier: shortcutModifiers.metaKey || shortcutModifiers.ctrlKey, |
| 127 | }) === "queue"; |
| 128 | const alternateAction = alternateComposerDispatchAction(followUpBehavior); |
| 129 | const isSendDisabled = !canOperateThread || sendDisabledReason !== null; |
| 130 | const stageBackdropVariant = useSidebarStageBackdropVariant( |
| 131 | environmentIdentificationMode === "artwork", |
| 132 | ); |
| 133 | |
| 134 | const renderStopGenerationButton = (insidePendingAction: boolean) => ( |
| 135 | <Tooltip key="interrupt"> |
| 136 | <TooltipTrigger |
| 137 | render={ |
| 138 | <button |
| 139 | type="button" |
| 140 | className={cn( |
| 141 | "flex shrink-0 cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-control-highlight transition-colors duration-150 hover:bg-destructive active:inset-shadow-control-pressed active:shadow-none [&_svg]:pointer-events-none", |
| 142 | insidePendingAction ? "size-8 sm:size-7" : "size-8 sm:h-8 sm:w-8", |
| 143 | )} |
| 144 | {...pointerFocusProps} |
| 145 | disabled={!canOperateThread} |
| 146 | onClick={() => { |
| 147 | if (canOperateThread) onInterrupt(); |
| 148 | }} |
| 149 | aria-label="Stop generation" |
| 150 | /> |
| 151 | } |
| 152 | > |
| 153 | <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"> |
| 154 | <rect x="2" y="2" width="8" height="8" rx="1.5" /> |
| 155 | </svg> |
| 156 | </TooltipTrigger> |
| 157 | <TooltipPopup>Interrupt</TooltipPopup> |
| 158 | </Tooltip> |
| 159 | ); |
| 160 | |
| 161 | if (pendingAction) { |
| 162 | return ( |
| 163 | <div className={cn("flex items-center justify-end", compact ? "gap-1.5" : "gap-2")}> |
| 164 | {canInterrupt ? renderStopGenerationButton(true) : null} |
| 165 | {pendingAction.questionIndex > 0 ? ( |
| 166 | compact ? ( |
| 167 | <Button |
| 168 | size="icon-sm" |
| 169 | variant="outline" |
| 170 | {...pointerFocusProps} |
| 171 | onClick={onPreviousPendingQuestion} |
| 172 | disabled={pendingAction.isResponding} |
| 173 | aria-label="Previous question" |
| 174 | > |
| 175 | <ChevronLeftIcon className="size-3.5" /> |
| 176 | </Button> |
| 177 | ) : ( |
| 178 | <Button |
| 179 | size="sm" |
| 180 | variant="outline" |
| 181 | {...pointerFocusProps} |
| 182 | onClick={onPreviousPendingQuestion} |
| 183 | disabled={pendingAction.isResponding} |
| 184 | > |
| 185 | Previous |
| 186 | </Button> |
| 187 | ) |
| 188 | ) : null} |
| 189 | <button |
| 190 | type="submit" |
| 191 | className={cn(messageActionPillClassName, "h-8 sm:h-7", compact ? "px-3" : "px-4")} |
| 192 | {...pointerFocusProps} |
| 193 | disabled={ |
| 194 | !canOperateThread || |
| 195 | isEnvironmentUnavailable || |
| 196 | pendingAction.isResponding || |
| 197 | (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance) |
| 198 | } |
| 199 | > |
| 200 | {formatPendingPrimaryActionLabel({ |
| 201 | compact, |
| 202 | isLastQuestion: pendingAction.isLastQuestion, |
| 203 | isResponding: pendingAction.isResponding, |
| 204 | questionIndex: pendingAction.questionIndex, |
| 205 | })} |
| 206 | </button> |
| 207 | </div> |
| 208 | ); |
| 209 | } |
| 210 | |
| 211 | if (showPlanFollowUpPrompt && (promptHasText || !canResume)) { |
| 212 | if (promptHasText) { |
| 213 | return ( |
| 214 | <button |
| 215 | type="submit" |
| 216 | className={cn(messageActionPillClassName, "h-9 sm:h-8", compact ? "px-3" : "px-4")} |
| 217 | {...pointerFocusProps} |
| 218 | disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} |
| 219 | > |
| 220 | {isConnecting || isSendBusy ? "Sending..." : "Refine"} |
| 221 | </button> |
| 222 | ); |
| 223 | } |
| 224 | |
| 225 | return ( |
| 226 | <div data-chat-composer-implement-actions="true" className="flex items-center justify-end"> |
| 227 | <button |
| 228 | type="submit" |
| 229 | className={cn(messageActionPillClassName, "h-9 rounded-r-none px-4 sm:h-8")} |
| 230 | {...pointerFocusProps} |
| 231 | disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} |
| 232 | > |
| 233 | {isConnecting || isSendBusy ? "Sending..." : "Implement"} |
| 234 | </button> |
| 235 | <Menu> |
| 236 | <MenuTrigger |
| 237 | render={ |
| 238 | <button |
| 239 | type="button" |
| 240 | className={cn( |
| 241 | messageActionPillClassName, |
| 242 | "h-9 rounded-l-none border-l border-message-action-foreground/20 px-2 sm:h-8", |
| 243 | )} |
| 244 | aria-label="Implementation actions" |
| 245 | {...pointerFocusProps} |
| 246 | disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} |
| 247 | /> |
| 248 | } |
| 249 | > |
| 250 | <ChevronDownIcon className="size-3.5" /> |
| 251 | </MenuTrigger> |
| 252 | <MenuPopup align="end" side="top" {...composerFloatingLayerProps}> |
| 253 | <MenuItem |
| 254 | disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} |
| 255 | onClick={() => { |
| 256 | if (canOperateThread) onImplementPlanInNewThread(); |
| 257 | }} |
| 258 | > |
| 259 | Implement in a new thread |
| 260 | </MenuItem> |
| 261 | </MenuPopup> |
| 262 | </Menu> |
| 263 | </div> |
| 264 | ); |
| 265 | } |
| 266 | |
| 267 | if (canInterrupt && !hasSendableContent && !isEditingQueuedMessage) { |
| 268 | return renderStopGenerationButton(false); |
| 269 | } |
| 270 | |
| 271 | const showResume = canResume && !hasSendableContent && !isEditingQueuedMessage; |
| 272 | |
| 273 | const compactTokens = |
| 274 | compactBeforeSendTokens !== null && !showResume && !isEditingQueuedMessage |
| 275 | ? formatContextWindowTokens(compactBeforeSendTokens) |
| 276 | : null; |
| 277 | const compactsBeforeSend = compactTokens !== null && !keepFullHistory; |
| 278 | |
| 279 | const submitLabel = showResume |
| 280 | ? "Resume thread" |
| 281 | : isEditingQueuedMessage |
| 282 | ? "Update queued message" |
| 283 | : compactsBeforeSend |
| 284 | ? "Compact and send" |
| 285 | : isQueuing |
| 286 | ? "Queue message" |
| 287 | : isRunning |
| 288 | ? "Steer message" |
| 289 | : "Submit message"; |
| 290 | const submitStatus = isEnvironmentUnavailable |
| 291 | ? "Environment disconnected" |
| 292 | : (sendDisabledReason ?? |
| 293 | (isConnecting |
| 294 | ? "Connecting" |
| 295 | : isPreparingWorktree |
| 296 | ? "Preparing worktree" |
| 297 | : isSendBusy |
| 298 | ? isEditingQueuedMessage |
| 299 | ? "Updating queued message" |
| 300 | : "Submitting message" |
| 301 | : null)); |
| 302 | const submitTooltip = |
| 303 | submitStatus ?? |
| 304 | (compactsBeforeSend |
| 305 | ? `Summarize ${compactTokens} tokens of history, then send` |
| 306 | : isRunning && !isEditingQueuedMessage |
| 307 | ? `Click to ${followUpBehavior}, Ctrl/⌘-click${alternateShortcutLabel ? ` or ${alternateShortcutLabel}` : ""} to ${alternateAction}` |
| 308 | : submitLabel); |
| 309 | |
| 310 | const sendButton = ( |
| 311 | <button |
| 312 | type={showResume ? "button" : "submit"} |
| 313 | className={cn( |
| 314 | "relative isolate flex h-9 w-9 items-center justify-center overflow-hidden rounded-full shadow-xs transition-all duration-150 enabled:cursor-pointer enabled:inset-shadow-control-highlight hover:scale-105 active:inset-shadow-control-pressed active:shadow-none disabled:pointer-events-none disabled:opacity-64 disabled:shadow-none disabled:hover:scale-100 sm:h-8 sm:w-8 [&_svg]:pointer-events-none", |
| 315 | stageBackdropVariant |
| 316 | ? "bg-transparent text-white enabled:shadow-black/24 enabled:hover:brightness-110" |
| 317 | : "bg-message-action text-message-action-foreground enabled:shadow-message-action/24 hover:bg-message-action-hover", |
| 318 | )} |
| 319 | {...pointerFocusProps} |
| 320 | onClick={showResume ? onResume : onSubmitMessage} |
| 321 | disabled={ |
| 322 | isSendBusy || |
| 323 | isSendDisabled || |
| 324 | isConnecting || |
| 325 | isEnvironmentUnavailable || |
| 326 | (!hasSendableContent && !showResume) |
| 327 | } |
| 328 | aria-label={submitStatus ?? submitLabel} |
| 329 | > |
| 330 | {stageBackdropVariant ? ( |
| 331 | <span className="pointer-events-none absolute inset-0 -z-10" aria-hidden="true"> |
| 332 | <StageBackdropButtonArt variant={stageBackdropVariant} /> |
| 333 | </span> |
| 334 | ) : null} |
| 335 | {isConnecting || isSendBusy ? ( |
| 336 | <Spinner size="sm" aria-hidden="true" /> |
| 337 | ) : showResume ? ( |
| 338 | <PlayIcon className="size-4 fill-current" aria-hidden="true" /> |
| 339 | ) : isEditingQueuedMessage ? ( |
| 340 | <CheckIcon className="size-4" aria-hidden="true" /> |
| 341 | ) : isRunning ? ( |
| 342 | <MorphIcon className="size-4" icon={isQueuing ? ListPlus : CornerUpRight} /> |
| 343 | ) : ( |
| 344 | <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"> |
| 345 | <path |
| 346 | d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5" |
| 347 | stroke="currentColor" |
| 348 | strokeWidth="1.8" |
| 349 | strokeLinecap="round" |
| 350 | strokeLinejoin="round" |
| 351 | /> |
| 352 | </svg> |
| 353 | )} |
| 354 | </button> |
| 355 | ); |
| 356 | |
| 357 | const submit = ( |
| 358 | <Tooltip key="submit"> |
| 359 | <TooltipTrigger render={<span className="inline-flex" />}>{sendButton}</TooltipTrigger> |
| 360 | <TooltipPopup>{submitTooltip}</TooltipPopup> |
| 361 | </Tooltip> |
| 362 | ); |
| 363 | if (compactTokens === null) return submit; |
| 364 | |
| 365 | return ( |
| 366 | <div data-chat-composer-compact-send="true" className="flex items-center gap-2"> |
| 367 | <Tooltip> |
| 368 | <TooltipTrigger |
| 369 | render={ |
| 370 | <button |
| 371 | type="button" |
| 372 | className={cn( |
| 373 | "flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-2.5 text-xs tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-64 sm:h-8 [&_svg]:pointer-events-none [&_svg]:size-3.5", |
| 374 | keepFullHistory |
| 375 | ? "border-border text-muted-foreground hover:text-foreground" |
| 376 | : "border-warning/40 text-warning hover:bg-warning/8", |
| 377 | )} |
| 378 | {...pointerFocusProps} |
| 379 | aria-pressed={!keepFullHistory} |
| 380 | aria-label={`Compact ${compactTokens} tokens of history before sending`} |
| 381 | disabled={!canOperateThread} |
| 382 | onClick={onToggleKeepFullHistory} |
| 383 | /> |
| 384 | } |
| 385 | > |
| 386 | <Minimize2Icon aria-hidden="true" /> |
| 387 | {keepFullHistory ? "Full" : "Compact"} |
| 388 | <span>{compactTokens}</span> |
| 389 | </TooltipTrigger> |
| 390 | <TooltipPopup> |
| 391 | {keepFullHistory |
| 392 | ? `Next send keeps all ${compactTokens} tokens. Click to compact first` |
| 393 | : `Next send compacts ${compactTokens} tokens first. Click to keep full history`} |
| 394 | </TooltipPopup> |
| 395 | </Tooltip> |
| 396 | {submit} |
| 397 | </div> |
| 398 | ); |
| 399 | }); |