PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.4
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.4
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / src / components / input / InputBox.jsx

InputBox.jsx in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.4, at src/components/input/InputBox.jsx

866 lines 36.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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