| 1 |
import React, { useState, useRef, forwardRef, useImperativeHandle, useCallback, useId, useEffect, useMemo } from 'react'; |
| 2 |
import { useAui, useAuiState, ComposerPrimitive } from '@assistant-ui/react'; |
| 3 |
import { useMessageSubmit } from '../../hooks'; |
| 4 |
import { useImageAttachment } from '../../hooks/useImageAttachment'; |
| 5 |
import { useChat } from '../../context/ChatContext'; |
| 6 |
import { useScreenshot } from '../../context/ScreenshotContext'; |
| 7 |
import { useEditorStore } from '../../store/useEditorStore'; |
| 8 |
import { useInlineEditStore } from '../../store/useInlineEditStore'; |
| 9 |
import { useChatExecutionStore } from '../../store/useChatExecutionStore'; |
| 10 |
import { useSlashCommands } from '../../hooks/useSlashCommands'; |
| 11 |
import { useBlockEditor } from '../../hooks/useBlockEditor'; |
| 12 |
import { clearSession } from '../../actions/sessionActions'; |
| 13 |
import { MAX_WORDS } from '../../constants'; |
| 14 |
import { cn } from '../../lib/cn'; |
| 15 |
import useDisplayModeStore from '../../store/useDisplayModeStore'; |
| 16 |
import { Button } from '../ui/button'; |
| 17 |
import { Plus, X, Trash, Camera, Paperclip, Square, ArrowUp, ArrowRight, ImagePlus } from 'lucide-react'; |
| 18 |
import DictateButton from '../DictateButton'; |
| 19 |
import BlockContextCard from './BlockContextCard'; |
| 20 |
import QuickEditActions from './QuickEditActions'; |
| 21 |
import AttachmentPreview from './AttachmentPreview'; |
| 22 |
import MediaUploadToast from './MediaUploadToast'; |
| 23 |
import SlashMenu from './SlashMenu'; |
| 24 |
import ScreenshotPanel from '../screenshot/ScreenshotPanel'; |
| 25 |
|
| 26 |
const InputBox = forwardRef(({ heroMode = false, ...props }, ref) => { |
| 27 |
const aui = useAui(); |
| 28 |
const composerText = useAuiState((s) => s.composer.text ?? ''); |
| 29 |
// Stop button visibility: include `isLoading` (chat execution store) so |
| 30 |
// the button stays visible across the entire turn lifetime — including |
| 31 |
// server-side fast-path execution after streaming pauses between events |
| 32 |
// and any window where assistant-ui briefly flips `thread.isRunning` |
| 33 |
// back to false. Without this, users had no way to abort a long-running |
| 34 |
// turn once streaming temporarily idled. |
| 35 |
const auiRunning = useAuiState((s) => s.thread.isRunning); |
| 36 |
const execLoading = useChatExecutionStore((s) => s.isLoading); |
| 37 |
const threadRunning = auiRunning || execLoading; |
| 38 |
const [showClearConfirm, setShowClearConfirm] = useState(false); |
| 39 |
const [showSlashMenu, setShowSlashMenu] = useState(false); |
| 40 |
const [isDragOver, setIsDragOver] = useState(false); |
| 41 |
const [showMediaToast, setShowMediaToast] = useState(false); |
| 42 |
const textareaRef = useRef(null); |
| 43 |
const fileInputRef = useRef(null); |
| 44 |
const clearConfirmTimeoutRef = useRef(null); |
| 45 |
const composerTextRef = useRef(composerText); |
| 46 |
composerTextRef.current = composerText; |
| 47 |
const inputId = useId(); |
| 48 |
|
| 49 |
const { isFullPage } = useDisplayModeStore(); |
| 50 |
const { submitMessage, setMetadata, isLoading, stopChat } = useMessageSubmit(); |
| 51 |
const { startNewChat, sessionId, messages, setError } = useChat(); |
| 52 |
const { selectedBlock, updateSelectedBlock, lockBlockContext, unlockBlockContext, clearSelectedBlock, contextBlocks, unpinContextBlock } = useEditorStore(); |
| 53 |
const { pendingEdit } = useInlineEditStore(); |
| 54 |
const { isBlockEditor } = useBlockEditor(); |
| 55 |
const { |
| 56 |
hasAttachment, displayUrl, isAnnotated, source: imageSource, |
| 57 |
error: imageError, |
| 58 |
attachImage, removeImage, |
| 59 |
getBase64ForSubmit, setRawDirectly, setAnnotatedDirectly, |
| 60 |
} = useImageAttachment(); |
| 61 |
|
| 62 |
const { |
| 63 |
isCapturing: isScreenshotCapturing, |
| 64 |
capturedImage, |
| 65 |
showScreenshotPanel, |
| 66 |
openScreenshotPanel, |
| 67 |
clearCapturedImage, |
| 68 |
} = useScreenshot(); |
| 69 |
|
| 70 |
// Annotation now happens on the live page via ScreenshotContext (DOM overlay). |
| 71 |
// No AnnotationCanvas component needed here. |
| 72 |
|
| 73 |
// Slash command handlers |
| 74 |
const slashHandlers = useMemo(() => ({ |
| 75 |
screenshot: openScreenshotPanel, |
| 76 |
}), [openScreenshotPanel]); |
| 77 |
|
| 78 |
const { matchingCommands, executeCommand } = useSlashCommands(slashHandlers); |
| 79 |
|
| 80 |
// Pick up capturedImage from ScreenshotContext (both plain and annotated captures) |
| 81 |
useEffect(() => { |
| 82 |
if (capturedImage?.dataUrl) { |
| 83 |
if (capturedImage.isAnnotated) { |
| 84 |
setRawDirectly(capturedImage.dataUrl); |
| 85 |
setAnnotatedDirectly(capturedImage.dataUrl); |
| 86 |
} else { |
| 87 |
setRawDirectly(capturedImage.dataUrl); |
| 88 |
} |
| 89 |
clearCapturedImage(); |
| 90 |
} |
| 91 |
}, [capturedImage, setRawDirectly, setAnnotatedDirectly, clearCapturedImage]); |
| 92 |
|
| 93 |
// Show media upload toast when an image is pasted or uploaded (not for screenshots) |
| 94 |
useEffect(() => { |
| 95 |
if (hasAttachment && (imageSource === 'paste' || imageSource === 'upload')) { |
| 96 |
setShowMediaToast(true); |
| 97 |
} else { |
| 98 |
setShowMediaToast(false); |
| 99 |
} |
| 100 |
}, [hasAttachment, imageSource]); |
| 101 |
|
| 102 |
// Memoized callbacks for MediaUploadToast (avoids re-creating on every render which resets timers) |
| 103 |
const handleToastDismiss = useCallback(() => setShowMediaToast(false), []); |
| 104 |
|
| 105 |
// Handle successful media upload — clear image attachment, paste media URL into composer |
| 106 |
const handleMediaUploadSuccess = useCallback((mediaUrl) => { |
| 107 |
if (!mediaUrl) return; |
| 108 |
// Read latest text from ref — avoids stale closure and per-keystroke callback re-creation |
| 109 |
const current = composerTextRef.current ?? ''; |
| 110 |
const separator = current && !current.endsWith(' ') && !current.endsWith('\n') ? ' ' : ''; |
| 111 |
aui.composer().setText(current + separator + mediaUrl); |
| 112 |
// Use rAF so setText flushes before removeImage triggers re-render |
| 113 |
requestAnimationFrame(() => { |
| 114 |
removeImage(); |
| 115 |
textareaRef.current?.focus(); |
| 116 |
}); |
| 117 |
}, [removeImage, aui]); |
| 118 |
|
| 119 |
// Show/hide slash menu based on input |
| 120 |
useEffect(() => { |
| 121 |
if (composerText.startsWith('/')) { |
| 122 |
const matched = matchingCommands(composerText); |
| 123 |
setShowSlashMenu(matched.length > 0); |
| 124 |
} else { |
| 125 |
setShowSlashMenu(false); |
| 126 |
} |
| 127 |
}, [composerText, matchingCommands]); |
| 128 |
|
| 129 |
// Quick edit action handler: submit crafted prompt with inline_edit_intent |
| 130 |
// Don't clear selectedBlock — keep badge attached for follow-up messages. |
| 131 |
const handleQuickAction = useCallback((prompt, intentId) => { |
| 132 |
if (isLoading || !prompt) return; |
| 133 |
aui.composer().setText(''); |
| 134 |
submitMessage(prompt, { inline_edit_intent: intentId }); |
| 135 |
}, [isLoading, submitMessage, aui]); |
| 136 |
|
| 137 |
// Focus textarea after response completes — but NOT when an interactive |
| 138 |
// component (smart form, approval card) was streamed, since the user may |
| 139 |
// be interacting with it and stealing focus is disruptive. |
| 140 |
const prevLoadingRef = useRef(isLoading); |
| 141 |
const hadComponentRef = useRef(false); |
| 142 |
|
| 143 |
// Track whether a component was streamed during this loading cycle |
| 144 |
const hasStreamedComponent = useChatExecutionStore((s) => s.hasStreamedComponent); |
| 145 |
useEffect(() => { |
| 146 |
if (isLoading && hasStreamedComponent) { |
| 147 |
hadComponentRef.current = true; |
| 148 |
} |
| 149 |
}, [isLoading, hasStreamedComponent]); |
| 150 |
|
| 151 |
useEffect(() => { |
| 152 |
if (prevLoadingRef.current && !isLoading) { |
| 153 |
if (!hadComponentRef.current) { |
| 154 |
textareaRef.current?.focus(); |
| 155 |
} |
| 156 |
hadComponentRef.current = false; |
| 157 |
} |
| 158 |
prevLoadingRef.current = isLoading; |
| 159 |
}, [isLoading]); |
| 160 |
|
| 161 |
// Listen for "Edit with AI" / Cmd+J shortcut — read the currently |
| 162 |
// selected Gutenberg block and attach it as context. |
| 163 |
useEffect(() => { |
| 164 |
const appBridge = window.zipAiMcpAppBridge; |
| 165 |
if (!appBridge) return; |
| 166 |
|
| 167 |
const handler = () => { |
| 168 |
// Read the currently selected block from Gutenberg |
| 169 |
if (window.wp?.data) { |
| 170 |
const blockEditorSelect = window.wp.data.select('core/block-editor'); |
| 171 |
const block = blockEditorSelect?.getSelectedBlock(); |
| 172 |
if (block) { |
| 173 |
const utils = window.zipAiMcpSpectraUtils; |
| 174 |
const serialized = utils?.serializeBlockLight |
| 175 |
? utils.serializeBlockLight(block) |
| 176 |
: { clientId: block.clientId, name: block.name }; |
| 177 |
updateSelectedBlock(serialized); |
| 178 |
} |
| 179 |
} |
| 180 |
lockBlockContext(); |
| 181 |
setTimeout(() => textareaRef.current?.focus(), 50); |
| 182 |
}; |
| 183 |
|
| 184 |
appBridge.on('inline_edit_shortcut', handler); |
| 185 |
return () => appBridge.off('inline_edit_shortcut'); |
| 186 |
}, [lockBlockContext, updateSelectedBlock]); |
| 187 |
|
| 188 |
// Global keyboard shortcuts (scoped to chat panel) |
| 189 |
useEffect(() => { |
| 190 |
const handleGlobalKey = (e) => { |
| 191 |
const meta = e.metaKey || e.ctrlKey; |
| 192 |
if (!meta) return; |
| 193 |
|
| 194 |
// Cmd+N — new chat (disabled while loading) |
| 195 |
if (e.key === 'n' && !e.shiftKey) { |
| 196 |
e.preventDefault(); |
| 197 |
if (isLoading) return; |
| 198 |
startNewChat(); |
| 199 |
setTimeout(() => textareaRef.current?.focus(), 50); |
| 200 |
return; |
| 201 |
} |
| 202 |
|
| 203 |
// Cmd+Shift+Backspace — clear/stop |
| 204 |
if (e.key === 'Backspace' && e.shiftKey) { |
| 205 |
e.preventDefault(); |
| 206 |
if (isLoading) { |
| 207 |
stopChat(); |
| 208 |
} |
| 209 |
return; |
| 210 |
} |
| 211 |
|
| 212 |
// Cmd+/ — focus input |
| 213 |
if (e.key === '/') { |
| 214 |
e.preventDefault(); |
| 215 |
textareaRef.current?.focus(); |
| 216 |
return; |
| 217 |
} |
| 218 |
}; |
| 219 |
|
| 220 |
document.addEventListener('keydown', handleGlobalKey); |
| 221 |
return () => document.removeEventListener('keydown', handleGlobalKey); |
| 222 |
}, [startNewChat, isLoading, stopChat]); |
| 223 |
|
| 224 |
// Sticky block context: lock when typing. Don't unlock on empty input |
| 225 |
// if a block is selected — the user may be sending multiple messages |
| 226 |
// about the same block. Unlock only happens via clearSelectedBlock (X button). |
| 227 |
useEffect(() => { |
| 228 |
if (composerText.trim()) { |
| 229 |
lockBlockContext(); |
| 230 |
} |
| 231 |
}, [composerText, lockBlockContext]); |
| 232 |
|
| 233 |
// Clean up confirm timeout |
| 234 |
useEffect(() => { |
| 235 |
return () => { |
| 236 |
if (clearConfirmTimeoutRef.current) { |
| 237 |
clearTimeout(clearConfirmTimeoutRef.current); |
| 238 |
} |
| 239 |
}; |
| 240 |
}, []); |
| 241 |
|
| 242 |
// Handle transcript from DictateButton |
| 243 |
const handleTranscript = useCallback((transcript) => { |
| 244 |
const cur = textareaRef.current?.value ?? ''; |
| 245 |
const spacer = cur && !cur.endsWith(' ') ? ' ' : ''; |
| 246 |
aui.composer().setText(cur + spacer + transcript); |
| 247 |
}, [aui]); |
| 248 |
|
| 249 |
// Expose setInput, setMetadata, and submit to parent via ref |
| 250 |
useImperativeHandle(ref, () => ({ |
| 251 |
setInput: (value) => { |
| 252 |
aui.composer().setText(value ?? ''); |
| 253 |
setTimeout(() => { |
| 254 |
const ta = textareaRef.current; |
| 255 |
if (!ta) return; |
| 256 |
ta.focus(); |
| 257 |
const end = ta.value.length; |
| 258 |
ta.setSelectionRange(end, end); |
| 259 |
}, 0); |
| 260 |
}, |
| 261 |
setMetadata, |
| 262 |
submit: () => { |
| 263 |
const form = textareaRef.current?.closest('form'); |
| 264 |
if (form) { |
| 265 |
form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); |
| 266 |
} |
| 267 |
} |
| 268 |
}), [aui, setMetadata]); |
| 269 |
|
| 270 |
const handleSubmit = async (e) => { |
| 271 |
e.preventDefault(); |
| 272 |
const raw = textareaRef.current?.value ?? ''; |
| 273 |
const trimmed = raw.trim(); |
| 274 |
if ((!trimmed && !hasAttachment) || isLoading || isOverLimit) return; |
| 275 |
|
| 276 |
// Internally trim to MAX_WORDS characters |
| 277 |
const message = trimmed.slice(0, MAX_WORDS); |
| 278 |
const imageData = getBase64ForSubmit(); |
| 279 |
aui.composer().setText(''); |
| 280 |
removeImage(); |
| 281 |
// Don't clear selectedBlock on send — keep it attached until user dismisses it. |
| 282 |
// This lets the user send multiple messages about the same block without re-selecting. |
| 283 |
await submitMessage(message || 'What do you see in this screenshot?', {}, imageData); |
| 284 |
}; |
| 285 |
|
| 286 |
const handleKeyDown = (e) => { |
| 287 |
// Let SlashMenu handle arrow/enter/escape when visible |
| 288 |
if (showSlashMenu) return; |
| 289 |
|
| 290 |
if (e.key === 'Enter' && !e.shiftKey) { |
| 291 |
e.preventDefault(); |
| 292 |
handleSubmit(e); |
| 293 |
} |
| 294 |
}; |
| 295 |
|
| 296 |
/** |
| 297 |
* Handle slash command selection from SlashMenu. |
| 298 |
* |
| 299 |
* @since x.x.x |
| 300 |
*/ |
| 301 |
const handleSlashSelect = useCallback((cmd) => { |
| 302 |
aui.composer().setText(''); |
| 303 |
setShowSlashMenu(false); |
| 304 |
executeCommand(cmd.name); |
| 305 |
}, [executeCommand, aui]); |
| 306 |
|
| 307 |
/** |
| 308 |
* Close the slash menu. |
| 309 |
* |
| 310 |
* @since x.x.x |
| 311 |
*/ |
| 312 |
const handleSlashClose = useCallback(() => { |
| 313 |
setShowSlashMenu(false); |
| 314 |
}, []); |
| 315 |
|
| 316 |
/** |
| 317 |
* Handle file input change for file attachment. |
| 318 |
* |
| 319 |
* @since x.x.x |
| 320 |
*/ |
| 321 |
const handleFileChange = useCallback((e) => { |
| 322 |
const file = e.target.files?.[0]; |
| 323 |
if (file) { |
| 324 |
attachImage(file, 'upload'); |
| 325 |
} |
| 326 |
// Reset input so the same file can be re-selected |
| 327 |
if (fileInputRef.current) { |
| 328 |
fileInputRef.current.value = ''; |
| 329 |
} |
| 330 |
}, [attachImage]); |
| 331 |
|
| 332 |
/** |
| 333 |
* Handle paste event — check for image clipboard items. |
| 334 |
* |
| 335 |
* @since x.x.x |
| 336 |
*/ |
| 337 |
const handlePaste = useCallback((e) => { |
| 338 |
const items = e.clipboardData?.items; |
| 339 |
if (!items) return; |
| 340 |
|
| 341 |
for (let i = 0; i < items.length; i++) { |
| 342 |
if (items[i].type.startsWith('image/')) { |
| 343 |
e.preventDefault(); |
| 344 |
const file = items[i].getAsFile(); |
| 345 |
if (file) { |
| 346 |
attachImage(file, 'paste'); |
| 347 |
} |
| 348 |
return; |
| 349 |
} |
| 350 |
} |
| 351 |
}, [attachImage]); |
| 352 |
|
| 353 |
/** |
| 354 |
* Handle drag over — prevent default to allow drop. |
| 355 |
* |
| 356 |
* @since x.x.x |
| 357 |
*/ |
| 358 |
const handleDragOver = useCallback((e) => { |
| 359 |
e.preventDefault(); |
| 360 |
e.stopPropagation(); |
| 361 |
if (e.dataTransfer?.types?.includes('Files')) { |
| 362 |
setIsDragOver(true); |
| 363 |
} |
| 364 |
}, []); |
| 365 |
|
| 366 |
/** |
| 367 |
* Handle drag leave — clear drop zone highlight. |
| 368 |
*/ |
| 369 |
const handleDragLeave = useCallback((e) => { |
| 370 |
e.preventDefault(); |
| 371 |
e.stopPropagation(); |
| 372 |
if (!e.currentTarget.contains(e.relatedTarget)) { |
| 373 |
setIsDragOver(false); |
| 374 |
} |
| 375 |
}, []); |
| 376 |
|
| 377 |
/** |
| 378 |
* Handle drop — attach dropped image file. |
| 379 |
* |
| 380 |
* @since x.x.x |
| 381 |
*/ |
| 382 |
const handleDrop = useCallback((e) => { |
| 383 |
e.preventDefault(); |
| 384 |
e.stopPropagation(); |
| 385 |
setIsDragOver(false); |
| 386 |
const file = e.dataTransfer?.files?.[0]; |
| 387 |
if (file && file.type.startsWith('image/')) { |
| 388 |
attachImage(file, 'upload'); |
| 389 |
} |
| 390 |
}, [attachImage]); |
| 391 |
|
| 392 |
// Check if there are messages to show the quick actions |
| 393 |
const hasMessages = messages && messages.length > 0; |
| 394 |
const canClearChat = Boolean(hasMessages || sessionId); |
| 395 |
|
| 396 |
// Handle clear chat with inline confirm |
| 397 |
const performClear = useCallback(async () => { |
| 398 |
if (clearConfirmTimeoutRef.current) { |
| 399 |
clearTimeout(clearConfirmTimeoutRef.current); |
| 400 |
} |
| 401 |
setShowClearConfirm(false); |
| 402 |
if (sessionId) { |
| 403 |
try { |
| 404 |
await clearSession(sessionId); |
| 405 |
} catch (error) { |
| 406 |
setError(error?.message || 'Failed to clear chat'); |
| 407 |
return; |
| 408 |
} |
| 409 |
} |
| 410 |
removeImage(); |
| 411 |
startNewChat(); |
| 412 |
}, [sessionId, startNewChat, setError, removeImage]); |
| 413 |
|
| 414 |
const handleClearClick = useCallback(() => { |
| 415 |
if (!canClearChat) return; |
| 416 |
|
| 417 |
if (showClearConfirm) { |
| 418 |
performClear(); |
| 419 |
} else { |
| 420 |
setShowClearConfirm(true); |
| 421 |
clearConfirmTimeoutRef.current = setTimeout(() => { |
| 422 |
setShowClearConfirm(false); |
| 423 |
}, 3000); |
| 424 |
} |
| 425 |
}, [showClearConfirm, performClear, canClearChat]); |
| 426 |
|
| 427 |
const cancelClearConfirm = useCallback(() => { |
| 428 |
if (clearConfirmTimeoutRef.current) { |
| 429 |
clearTimeout(clearConfirmTimeoutRef.current); |
| 430 |
} |
| 431 |
setShowClearConfirm(false); |
| 432 |
}, []); |
| 433 |
|
| 434 |
// Character limit indicator |
| 435 |
const charCount = composerText.length; |
| 436 |
const showCharCount = charCount > MAX_WORDS * 0.8; |
| 437 |
const isOverLimit = charCount > MAX_WORDS; |
| 438 |
|
| 439 |
// Compute slash menu commands for current input |
| 440 |
const slashMenuCommands = showSlashMenu ? matchingCommands(composerText) : []; |
| 441 |
|
| 442 |
// Hero mode — byMiles-style large centered input (used by EmptyState fullpage) |
| 443 |
if (heroMode) { |
| 444 |
return ( |
| 445 |
<div |
| 446 |
className="relative" |
| 447 |
onDragOver={handleDragOver} |
| 448 |
onDragLeave={handleDragLeave} |
| 449 |
onDrop={handleDrop} |
| 450 |
> |
| 451 |
{isDragOver && ( |
| 452 |
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-primary bg-primary/5 backdrop-blur-[2px] pointer-events-none"> |
| 453 |
<div className="flex items-center gap-2 text-sm font-medium text-primary"> |
| 454 |
<ImagePlus size={20} /> |
| 455 |
Drop image here |
| 456 |
</div> |
| 457 |
</div> |
| 458 |
)} |
| 459 |
<div className="relative"> |
| 460 |
<MediaUploadToast |
| 461 |
visible={showMediaToast} |
| 462 |
getBase64ForSubmit={getBase64ForSubmit} |
| 463 |
onDismiss={handleToastDismiss} |
| 464 |
onUploadSuccess={handleMediaUploadSuccess} |
| 465 |
/> |
| 466 |
<ComposerPrimitive.Root |
| 467 |
aria-label="Send a message" |
| 468 |
onSubmit={(e) => { |
| 469 |
e.preventDefault(); |
| 470 |
handleSubmit(e); |
| 471 |
}} |
| 472 |
className={cn( |
| 473 |
'flex flex-col bg-background border border-border/50 rounded-xl', |
| 474 |
'shadow-lg', |
| 475 |
'focus-within:border-line-3', |
| 476 |
'transition-[border-color,box-shadow] duration-200' |
| 477 |
)} |
| 478 |
> |
| 479 |
{/* Hidden file input */} |
| 480 |
<input |
| 481 |
ref={fileInputRef} |
| 482 |
type="file" |
| 483 |
accept="image/jpeg,image/png,image/gif,image/webp" |
| 484 |
className="absolute w-0 h-0 overflow-hidden opacity-0 pointer-events-none" |
| 485 |
onChange={handleFileChange} |
| 486 |
tabIndex={-1} |
| 487 |
aria-hidden="true" |
| 488 |
/> |
| 489 |
|
| 490 |
<div className="px-4 pt-4 pb-3 space-y-2"> |
| 491 |
{showScreenshotPanel && <ScreenshotPanel />} |
| 492 |
{hasAttachment && ( |
| 493 |
<AttachmentPreview |
| 494 |
displayUrl={displayUrl} |
| 495 |
isAnnotated={isAnnotated} |
| 496 |
onRemove={removeImage} |
| 497 |
/> |
| 498 |
)} |
| 499 |
<div className="relative"> |
| 500 |
{showSlashMenu && ( |
| 501 |
<SlashMenu |
| 502 |
commands={slashMenuCommands} |
| 503 |
onSelect={handleSlashSelect} |
| 504 |
onClose={handleSlashClose} |
| 505 |
/> |
| 506 |
)} |
| 507 |
<ComposerPrimitive.Input |
| 508 |
ref={textareaRef} |
| 509 |
submitMode="none" |
| 510 |
addAttachmentOnPaste={false} |
| 511 |
onKeyDown={handleKeyDown} |
| 512 |
onPaste={handlePaste} |
| 513 |
placeholder="A dental practice website with services, team bios, and appointments..." |
| 514 |
className={cn( |
| 515 |
'w-full p-0 border-none outline-none shadow-none font-[inherit] leading-[1.6] appearance-none', |
| 516 |
'text-base resize-none min-h-[120px] max-h-[280px] transition-none bg-transparent text-body', |
| 517 |
'focus:outline-none focus:border-none focus:shadow-none focus:ring-0', |
| 518 |
'disabled:bg-transparent disabled:cursor-not-allowed disabled:opacity-60', |
| 519 |
'placeholder:text-muted-foreground/50' |
| 520 |
)} |
| 521 |
rows={4} |
| 522 |
/> |
| 523 |
</div> |
| 524 |
</div> |
| 525 |
|
| 526 |
{/* Bottom toolbar */} |
| 527 |
<div className="flex items-center justify-between border-t border-border/50 px-3 py-2.5"> |
| 528 |
{/* Left: tools */} |
| 529 |
<div className="flex items-center gap-1"> |
| 530 |
{showCharCount && ( |
| 531 |
<span className={cn( |
| 532 |
'text-xs tabular-nums transition-colors', |
| 533 |
isOverLimit ? 'text-destructive font-medium' : 'text-muted-foreground' |
| 534 |
)}> |
| 535 |
{charCount}/{MAX_WORDS} |
| 536 |
</span> |
| 537 |
)} |
| 538 |
<Button |
| 539 |
type="button" |
| 540 |
variant="ghost" |
| 541 |
size="icon" |
| 542 |
className="w-8 h-8 text-ink-4 hover:text-foreground hover:bg-sunken" |
| 543 |
onClick={() => fileInputRef.current?.click()} |
| 544 |
aria-label="Attach an image" |
| 545 |
> |
| 546 |
<Paperclip size={15} /> |
| 547 |
</Button> |
| 548 |
<Button |
| 549 |
type="button" |
| 550 |
variant="ghost" |
| 551 |
size="icon" |
| 552 |
className="w-8 h-8 text-ink-4 hover:text-foreground hover:bg-sunken" |
| 553 |
onClick={openScreenshotPanel} |
| 554 |
data-tooltip="Screenshot" |
| 555 |
aria-label="Take a screenshot" |
| 556 |
> |
| 557 |
<Camera size={16} /> |
| 558 |
</Button> |
| 559 |
<DictateButton onTranscript={handleTranscript} /> |
| 560 |
</div> |
| 561 |
|
| 562 |
{/* Right: shortcut hint + Start button */} |
| 563 |
<div className="flex items-center gap-2.5"> |
| 564 |
<span className="text-[12px] text-muted-foreground/40 hidden sm:block select-none"> |
| 565 |
⌘ + Enter |
| 566 |
</span> |
| 567 |
{threadRunning ? ( |
| 568 |
<Button |
| 569 |
type="button" |
| 570 |
variant="primaryInk" |
| 571 |
size="sm" |
| 572 |
className="rounded-lg gap-1.5 h-8 px-3" |
| 573 |
onClick={stopChat} |
| 574 |
aria-label="Stop generating" |
| 575 |
> |
| 576 |
<Square size={11} fill="currentColor" /> |
| 577 |
<span>Stop</span> |
| 578 |
</Button> |
| 579 |
) : ( |
| 580 |
<Button |
| 581 |
type="submit" |
| 582 |
variant="primaryInk" |
| 583 |
size="sm" |
| 584 |
className="rounded-lg gap-1.5 h-8 px-4" |
| 585 |
disabled={(!composerText.trim() && !hasAttachment) || isOverLimit} |
| 586 |
aria-label="Start" |
| 587 |
> |
| 588 |
<span>Start</span> |
| 589 |
<ArrowRight size={14} strokeWidth={2.5} aria-hidden="true" /> |
| 590 |
</Button> |
| 591 |
)} |
| 592 |
</div> |
| 593 |
</div> |
| 594 |
</ComposerPrimitive.Root> |
| 595 |
</div> |
| 596 |
</div> |
| 597 |
); |
| 598 |
} |
| 599 |
|
| 600 |
return ( |
| 601 |
<div |
| 602 |
className={cn( |
| 603 |
'flex flex-col border-t border-border bg-background shrink-0 will-change-transform relative', |
| 604 |
// In full-page mode, the outer ChatWindow wrapper already |
| 605 |
// constrains width via max-w-[760px] mx-auto, so we only |
| 606 |
// add vertical padding here to keep the column edges aligned |
| 607 |
// with the message list. |
| 608 |
isFullPage ? 'pb-5 pt-3' : 'px-3 pb-3 pt-2' |
| 609 |
)} |
| 610 |
role="region" |
| 611 |
aria-label="Message input" |
| 612 |
onDragOver={handleDragOver} |
| 613 |
onDragLeave={handleDragLeave} |
| 614 |
onDrop={handleDrop} |
| 615 |
> |
| 616 |
{isDragOver && ( |
| 617 |
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-primary bg-primary/5 backdrop-blur-[2px] pointer-events-none"> |
| 618 |
<div className="flex items-center gap-2 text-sm font-medium text-primary"> |
| 619 |
<ImagePlus size={20} /> |
| 620 |
Drop image here |
| 621 |
</div> |
| 622 |
</div> |
| 623 |
)} |
| 624 |
<div className="relative"> |
| 625 |
<MediaUploadToast |
| 626 |
visible={showMediaToast} |
| 627 |
getBase64ForSubmit={getBase64ForSubmit} |
| 628 |
onDismiss={handleToastDismiss} |
| 629 |
onUploadSuccess={handleMediaUploadSuccess} |
| 630 |
/> |
| 631 |
<ComposerPrimitive.Root |
| 632 |
aria-label="Send a message" |
| 633 |
onSubmit={(e) => { |
| 634 |
e.preventDefault(); |
| 635 |
handleSubmit(e); |
| 636 |
}} |
| 637 |
className={cn( |
| 638 |
'flex flex-col gap-0 bg-background border transition-[border-color,box-shadow] duration-200', |
| 639 |
isFullPage |
| 640 |
? [ |
| 641 |
'rounded-xl border-border/50', |
| 642 |
'shadow-md', |
| 643 |
'focus-within:border-line-3', |
| 644 |
] |
| 645 |
: [ |
| 646 |
'rounded-lg border-border/70', |
| 647 |
'shadow-sm', |
| 648 |
'focus-within:border-line-3', |
| 649 |
] |
| 650 |
)} |
| 651 |
> |
| 652 |
<div className="px-3 pt-3 pb-2 space-y-2"> |
| 653 |
{(selectedBlock || (contextBlocks && contextBlocks.length > 0)) && ( |
| 654 |
<div className="flex flex-wrap gap-1"> |
| 655 |
{selectedBlock && ( |
| 656 |
<BlockContextCard |
| 657 |
block={selectedBlock} |
| 658 |
onDismiss={clearSelectedBlock} |
| 659 |
/> |
| 660 |
)} |
| 661 |
{contextBlocks && contextBlocks.map((block) => ( |
| 662 |
<BlockContextCard |
| 663 |
key={block.clientId} |
| 664 |
block={block} |
| 665 |
onDismiss={() => unpinContextBlock(block.clientId)} |
| 666 |
pinned |
| 667 |
/> |
| 668 |
))} |
| 669 |
</div> |
| 670 |
)} |
| 671 |
{selectedBlock && !pendingEdit && ( |
| 672 |
<QuickEditActions |
| 673 |
selectedBlock={selectedBlock} |
| 674 |
onAction={handleQuickAction} |
| 675 |
disabled={isLoading} |
| 676 |
/> |
| 677 |
)} |
| 678 |
<label htmlFor={inputId} className="sr-only"> |
| 679 |
Type your message |
| 680 |
</label> |
| 681 |
{showScreenshotPanel && <ScreenshotPanel />} |
| 682 |
{hasAttachment && ( |
| 683 |
<AttachmentPreview |
| 684 |
displayUrl={displayUrl} |
| 685 |
isAnnotated={isAnnotated} |
| 686 |
onRemove={removeImage} |
| 687 |
/> |
| 688 |
)} |
| 689 |
{isScreenshotCapturing && !hasAttachment && ( |
| 690 |
<div className="flex items-center gap-2"> |
| 691 |
<div className={cn('w-[60px] h-[40px] rounded-md bg-muted animate-pulse')} /> |
| 692 |
<span className="text-xs text-faint">Capturing...</span> |
| 693 |
</div> |
| 694 |
)} |
| 695 |
<div className="relative"> |
| 696 |
{showSlashMenu && ( |
| 697 |
<SlashMenu |
| 698 |
commands={slashMenuCommands} |
| 699 |
onSelect={handleSlashSelect} |
| 700 |
onClose={handleSlashClose} |
| 701 |
/> |
| 702 |
)} |
| 703 |
<ComposerPrimitive.Input |
| 704 |
id={inputId} |
| 705 |
ref={textareaRef} |
| 706 |
submitMode="none" |
| 707 |
addAttachmentOnPaste={false} |
| 708 |
onKeyDown={handleKeyDown} |
| 709 |
onPaste={handlePaste} |
| 710 |
onFocus={lockBlockContext} |
| 711 |
placeholder="How can I help you today?" |
| 712 |
className={cn( |
| 713 |
'w-full p-0 border-none outline-none shadow-none font-[inherit] leading-[1.55] appearance-none', |
| 714 |
isFullPage ? 'text-base' : 'text-sm', |
| 715 |
'resize-none min-h-[44px] max-h-[200px] transition-none bg-transparent text-body', |
| 716 |
'focus:outline-none focus:border-none focus:shadow-none focus:ring-0', |
| 717 |
'disabled:bg-transparent disabled:cursor-not-allowed disabled:opacity-60', |
| 718 |
'placeholder:text-muted-foreground/70' |
| 719 |
)} |
| 720 |
rows={1} |
| 721 |
/> |
| 722 |
</div> |
| 723 |
</div> |
| 724 |
|
| 725 |
{/* Hidden file input for attachments */} |
| 726 |
<input |
| 727 |
ref={fileInputRef} |
| 728 |
type="file" |
| 729 |
accept="image/jpeg,image/png,image/gif,image/webp" |
| 730 |
className="absolute w-0 h-0 overflow-hidden opacity-0 pointer-events-none" |
| 731 |
onChange={handleFileChange} |
| 732 |
tabIndex={-1} |
| 733 |
aria-hidden="true" |
| 734 |
/> |
| 735 |
|
| 736 |
<div className="flex items-center justify-end gap-1 border-t border-border/60 px-2 py-1.5"> |
| 737 |
{/* Character count - appears at 80%+ */} |
| 738 |
{showCharCount && ( |
| 739 |
<span className={cn( |
| 740 |
'text-xs tabular-nums mr-auto transition-colors', |
| 741 |
isOverLimit ? 'text-destructive font-medium' : 'text-muted-foreground' |
| 742 |
)}> |
| 743 |
{charCount}/{MAX_WORDS} |
| 744 |
</span> |
| 745 |
)} |
| 746 |
<div className="flex items-center gap-1"> |
| 747 |
<> |
| 748 |
<Button |
| 749 |
type="button" |
| 750 |
variant="ghost" |
| 751 |
size="icon" |
| 752 |
className="w-7 h-7 text-ink-4 hover:text-foreground hover:bg-sunken" |
| 753 |
onClick={() => { removeImage(); startNewChat(); }} |
| 754 |
disabled={isLoading} |
| 755 |
data-tooltip="New Chat" |
| 756 |
aria-label="Start new chat" |
| 757 |
> |
| 758 |
<Plus size={16} /> |
| 759 |
</Button> |
| 760 |
{canClearChat && ( |
| 761 |
showClearConfirm ? ( |
| 762 |
<div className="flex items-center gap-0.5"> |
| 763 |
<Button |
| 764 |
type="button" |
| 765 |
size="sm" |
| 766 |
variant="destructive" |
| 767 |
className="h-6 px-2.5 text-xs" |
| 768 |
onClick={performClear} |
| 769 |
aria-label="Confirm clear chat" |
| 770 |
> |
| 771 |
Clear? |
| 772 |
</Button> |
| 773 |
<Button |
| 774 |
type="button" |
| 775 |
variant="ghost" |
| 776 |
size="icon" |
| 777 |
className="w-6 h-6 text-ink-4 hover:text-foreground hover:bg-sunken" |
| 778 |
onClick={cancelClearConfirm} |
| 779 |
aria-label="Cancel clear" |
| 780 |
> |
| 781 |
<X size={12} /> |
| 782 |
</Button> |
| 783 |
</div> |
| 784 |
) : ( |
| 785 |
<Button |
| 786 |
type="button" |
| 787 |
variant="ghost" |
| 788 |
size="icon" |
| 789 |
className="w-7 h-7 text-ink-4 hover:text-foreground hover:bg-sunken" |
| 790 |
onClick={handleClearClick} |
| 791 |
data-tooltip="Clear Chat" |
| 792 |
aria-label="Clear chat history" |
| 793 |
> |
| 794 |
<Trash size={15} /> |
| 795 |
</Button> |
| 796 |
) |
| 797 |
)} |
| 798 |
</> |
| 799 |
{/* Screenshot button — direct trigger for capture panel */} |
| 800 |
<Button |
| 801 |
type="button" |
| 802 |
variant="ghost" |
| 803 |
size="icon" |
| 804 |
className="w-7 h-7 text-ink-4 hover:text-foreground hover:bg-sunken" |
| 805 |
onClick={openScreenshotPanel} |
| 806 |
data-tooltip="Screenshot" |
| 807 |
aria-label="Take a screenshot" |
| 808 |
> |
| 809 |
<Camera size={16} /> |
| 810 |
</Button> |
| 811 |
|
| 812 |
{/* File attachment button */} |
| 813 |
<Button |
| 814 |
type="button" |
| 815 |
variant="ghost" |
| 816 |
size="icon" |
| 817 |
className="w-7 h-7 text-ink-4 hover:text-foreground hover:bg-sunken" |
| 818 |
onClick={() => fileInputRef.current?.click()} |
| 819 |
data-tooltip="Attach Image" |
| 820 |
aria-label="Attach an image" |
| 821 |
> |
| 822 |
<Paperclip size={15} /> |
| 823 |
</Button> |
| 824 |
|
| 825 |
<DictateButton onTranscript={handleTranscript} /> |
| 826 |
|
| 827 |
{threadRunning ? ( |
| 828 |
<Button |
| 829 |
type="button" |
| 830 |
variant="primaryInk" |
| 831 |
size="sm" |
| 832 |
className="rounded-md gap-1.5 h-7 px-2.5" |
| 833 |
onClick={stopChat} |
| 834 |
aria-label="Stop generating" |
| 835 |
> |
| 836 |
<Square size={11} fill="currentColor" /> |
| 837 |
<span>Stop</span> |
| 838 |
</Button> |
| 839 |
) : ( |
| 840 |
(composerText.trim() || hasAttachment) && ( |
| 841 |
<Button |
| 842 |
type="submit" |
| 843 |
variant="primaryInk" |
| 844 |
size="icon-sm" |
| 845 |
className="w-7 h-7 rounded-[7px]" |
| 846 |
disabled={isOverLimit} |
| 847 |
aria-label="Send message" |
| 848 |
> |
| 849 |
<ArrowUp size={14} strokeWidth={2.4} /> |
| 850 |
</Button> |
| 851 |
) |
| 852 |
)} |
| 853 |
</div> |
| 854 |
</div> |
| 855 |
</ComposerPrimitive.Root> |
| 856 |
</div> |
| 857 |
|
| 858 |
{/* Annotation happens on the live page via ScreenshotContext DOM overlay */} |
| 859 |
</div> |
| 860 |
); |
| 861 |
}); |
| 862 |
|
| 863 |
InputBox.displayName = 'InputBox'; |
| 864 |
|
| 865 |
export default InputBox; |
| 866 |
|