返回 JoyAI-Echo
ThreadShell.tsx
1 import {
2 type ReactNode,
3 useCallback,
4 useEffect,
5 useRef,
6 useState,
7 } from "react";
8 import { useTranslation } from "react-i18next";
9
10 import { ThreadComposer } from "@/components/thread/ThreadComposer";
11 import { ThreadHeader } from "@/components/thread/ThreadHeader";
12 import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
13 import { ThreadViewport } from "@/components/thread/ThreadViewport";
14 import {
15 type ThreadStreamControl,
16 useThreadStream,
17 } from "@/hooks/useThreadStream";
18 import type { SendImage } from "@/hooks/useNanobotStream";
19 import {
20 useFirstFrameImage,
21 type FirstFrameData,
22 } from "@/hooks/useFirstFrameImage";
23 import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
24 import { resolveQuestionCardsFromThread } from "@/lib/questions";
25 import {
26 ApiError,
27 deleteReferenceImage,
28 fetchGenerationSettings,
29 saveGenerationSettings,
30 uploadFirstFrameImage,
31 } from "@/lib/api";
32 import { classifyFirstFrameQuestion } from "@/lib/first-frame-question-actions";
33 import type {
34 ChatSummary,
35 MemoryReview,
36 MemoryWorkspaceAsset,
37 ShotMemoryAssetCreate,
38 UIMessage,
39 WorkplaceReferenceImage,
40 } from "@/lib/types";
41 import type { NextContinuousControl } from "@/components/thread/MemoryReviewCard";
42 import { useClient } from "@/providers/ClientProvider";
43 import {
44 LONG_VIDEO_DEFAULT_DURATION,
45 LONG_VIDEO_DURATION_OPTIONS,
46 } from "@/components/thread/DurationPicker";
47 import {
48 DEFAULT_VIDEO_SIZE,
49 type VideoSize,
50 } from "@/components/thread/AspectRatioPicker";
51 import {
52 DEFAULT_STORY_LANGUAGE,
53 type StoryLanguage,
54 STORY_LANGUAGE_PRESETS,
55 } from "@/components/thread/LanguagePicker";
56 import {
57 AlertDialog,
58 AlertDialogAction,
59 AlertDialogContent,
60 AlertDialogDescription,
61 AlertDialogFooter,
62 AlertDialogHeader,
63 AlertDialogTitle,
64 } from "@/components/ui/alert-dialog";
65 import { toast } from "@/components/ui/Toast";
66 import { FIRST_FRAME_UPLOAD_ENABLED } from "@/config/features";
67 import { cn } from "@/lib/utils";
68
69 function parseStoryLanguage(value: unknown): StoryLanguage {
70 if (typeof value !== "string") return DEFAULT_STORY_LANGUAGE;
71 const cleaned = value.trim();
72 if ((STORY_LANGUAGE_PRESETS as readonly string[]).includes(cleaned)) {
73 return cleaned as StoryLanguage;
74 }
75 const lowered = cleaned.toLowerCase();
76 if (
77 lowered === "zh" ||
78 lowered === "zh-cn" ||
79 lowered === "chinese" ||
80 cleaned === "中文"
81 ) {
82 return "zh";
83 }
84 if (lowered === "en" || lowered === "en-us" || lowered === "english") {
85 return "en";
86 }
87 return DEFAULT_STORY_LANGUAGE;
88 }
89
90 interface ThreadShellProps {
91 session: ChatSummary | null;
92 title: string;
93 onToggleSidebar: () => void;
94 onGoHome: () => void;
95 onNewChat: () => Promise<string | null>;
96 hideSidebarToggleOnDesktop?: boolean;
97 /** Same as sidebar refresh — e.g. reload session titles after a reply. */
98 onReplyEnd?: () => void;
99 /** When provided, stream state is owned by the parent (e.g. ThreadWorkplaceShell). */
100 stream?: ThreadStreamControl;
101 mockMessages?: UIMessage[];
102 mockStreaming?: boolean;
103 mockSend?: (content: string) => void;
104 actionSlot?: ReactNode;
105 modeSelectorSlot?: ReactNode;
106 onMessageComplete?: (message: UIMessage) => void;
107 onAnswerQuestion?: (messageId: string, cardId: string, value: string) => void;
108 onMemoryReviewAction?: (
109 review: MemoryReview,
110 action: "approve" | "reselect" | "manual_select" | "select_mode",
111 memoryId?: string,
112 timestampSec?: number,
113 selectionMode?: "manual" | "vlm",
114 retainedMemoryIds?: string[],
115 ) => void | Promise<void>;
116 memoryAssets?: MemoryWorkspaceAsset[];
117 onCreateMemoryAsset?: (
118 shotId: number,
119 asset: ShotMemoryAssetCreate,
120 ) => Promise<void>;
121 getNextContinuous?: (
122 review: MemoryReview,
123 ) => NextContinuousControl | null;
124 /** Blocks chat send while the right-side workplace textarea is focused. */
125 composerSendDisabled?: boolean;
126 videoSizeLocked?: boolean;
127 referenceImageLocked?: boolean;
128 persistedReferenceImage?: WorkplaceReferenceImage | null;
129 persistedAutoGenerate?: boolean;
130 forceAutoGenerate?: boolean;
131 onReferenceImageSynced?: () => void;
132 }
133
134 function toModelBadgeLabel(modelName: string | null): string | null {
135 if (!modelName) return null;
136 const trimmed = modelName.trim();
137 if (!trimmed) return null;
138 const leaf = trimmed.split("/").pop() ?? trimmed;
139 return leaf || trimmed;
140 }
141
142 type ThreadShellInnerProps = Omit<ThreadShellProps, "stream" | "onReplyEnd"> & {
143 stream: ThreadStreamControl;
144 };
145
146 function ThreadShellInner({
147 session,
148 title,
149 onToggleSidebar,
150 onGoHome,
151 onNewChat,
152 hideSidebarToggleOnDesktop = false,
153 stream,
154 mockMessages,
155 mockStreaming,
156 mockSend,
157 actionSlot,
158 modeSelectorSlot,
159 onMessageComplete,
160 onAnswerQuestion,
161 onMemoryReviewAction,
162 memoryAssets,
163 onCreateMemoryAsset,
164 getNextContinuous,
165 composerSendDisabled = false,
166 videoSizeLocked = false,
167 referenceImageLocked = false,
168 persistedReferenceImage = null,
169 persistedAutoGenerate = false,
170 forceAutoGenerate = false,
171 onReferenceImageSynced,
172 }: ThreadShellInnerProps) {
173 const { t } = useTranslation();
174 const {
175 chatId,
176 loading,
177 messages,
178 isStreaming,
179 turnComplete,
180 send,
181 answerQuestion,
182 streamError,
183 dismissStreamError,
184 } = stream;
185 const { client, modelName, token } = useClient();
186 const [booting, setBooting] = useState(false);
187 const [durationSec, setDurationSec] = useState(LONG_VIDEO_DEFAULT_DURATION);
188 const [videoSize, setVideoSize] = useState<VideoSize>(DEFAULT_VIDEO_SIZE);
189 const [storyLanguage, setStoryLanguage] = useState<StoryLanguage>(
190 DEFAULT_STORY_LANGUAGE,
191 );
192 const [autoGenerate, setAutoGenerate] = useState(
193 forceAutoGenerate || persistedAutoGenerate,
194 );
195 const [firstFrameUploadFailedOpen, setFirstFrameUploadFailedOpen] =
196 useState(false);
197 const [firstFrameUploadError, setFirstFrameUploadError] = useState("");
198 const [referencePreviewDismissed, setReferencePreviewDismissed] =
199 useState(false);
200 const durationSecRef = useRef(LONG_VIDEO_DEFAULT_DURATION);
201 const videoSizeRef = useRef<VideoSize>(DEFAULT_VIDEO_SIZE);
202 const storyLanguageRef = useRef<StoryLanguage>(DEFAULT_STORY_LANGUAGE);
203 const pendingDurationSaveRef = useRef(false);
204 const pendingFirstRef = useRef<string | null>(null);
205 const uploadedRefImageRef = useRef<WorkplaceReferenceImage | null>(null);
206 const lastPersistKeyRef = useRef<string | null>(null);
207 const autoGenerateRef = useRef(autoGenerate);
208 autoGenerateRef.current = autoGenerate;
209 const showHeroComposer = messages.length === 0 && !loading;
210
211 durationSecRef.current = durationSec;
212 videoSizeRef.current = videoSize;
213 storyLanguageRef.current = storyLanguage;
214
215 const isMockMode = !!mockSend;
216 const activeMessages = isMockMode
217 ? (mockMessages ?? [])
218 : resolveQuestionCardsFromThread(messages);
219 const activeStreaming = isMockMode ? (mockStreaming ?? false) : isStreaming;
220 const activeTurnComplete = isMockMode ? true : turnComplete;
221 const activeSend = isMockMode ? mockSend! : send;
222 const showHero = isMockMode ? activeMessages.length === 0 : showHeroComposer;
223
224 const showFirstFrameUploader = !isMockMode;
225
226 const onFirstFrameReject = useCallback(
227 (reason: "unsupported_type" | "decode_failed") => {
228 toast.error(t(`thread.firstFrame.rejected.${reason}`));
229 },
230 [t],
231 );
232
233 const {
234 value: firstFrame,
235 cropping: firstFrameCropping,
236 setFile: setFirstFrameFile,
237 clear: clearFirstFrame,
238 } = useFirstFrameImage(videoSize, onFirstFrameReject);
239
240 const firstFrameValueRef = useRef(firstFrame);
241 firstFrameValueRef.current = firstFrame;
242
243 const onPageImageFiles = useCallback(
244 (files: File[]) => {
245 if (
246 !FIRST_FRAME_UPLOAD_ENABLED ||
247 !showFirstFrameUploader ||
248 files.length === 0
249 ) {
250 return;
251 }
252 void setFirstFrameFile(files[0]!);
253 },
254 [showFirstFrameUploader, setFirstFrameFile],
255 );
256
257 const {
258 isDragging,
259 onDragEnter,
260 onDragOver,
261 onDragLeave,
262 onDrop,
263 } = useClipboardAndDrop(onPageImageFiles);
264
265 const prepareStepwiseReference = useCallback(
266 async (frame: FirstFrameData): Promise<WorkplaceReferenceImage | null> => {
267 const uploaded = await uploadFirstFrameImage(frame.croppedBlob, frame.name);
268 const image: WorkplaceReferenceImage = {
269 url: uploaded.url,
270 name: frame.name,
271 width: uploaded.width || frame.width,
272 height: uploaded.height || frame.height,
273 };
274 uploadedRefImageRef.current = image;
275 return image;
276 },
277 [],
278 );
279
280 const persistStepwiseReference = useCallback(
281 async (frame: FirstFrameData): Promise<WorkplaceReferenceImage | null> => {
282 const image = await prepareStepwiseReference(frame);
283 if (!image || !session?.key || !chatId) return image;
284 try {
285 await client.saveWorkplaceReferenceImage(chatId, image);
286 lastPersistKeyRef.current = `${frame.name}:${frame.width}x${frame.height}:${frame.croppedBlob.size}`;
287 onReferenceImageSynced?.();
288 } catch (err) {
289 if (err instanceof ApiError && err.status === 409) {
290 toast.error(t("thread.firstFrame.locked"));
291 return image;
292 }
293 throw err;
294 }
295 return image;
296 },
297 [
298 chatId,
299 client,
300 onReferenceImageSynced,
301 prepareStepwiseReference,
302 session?.key,
303 t,
304 ],
305 );
306
307 const showFirstFrameError = useCallback((error: unknown) => {
308 const detail =
309 error instanceof Error && error.message
310 ? error.message
311 : "unknown first-frame error";
312 console.error("First-frame send failed:", error);
313 setFirstFrameUploadError(detail);
314 setFirstFrameUploadFailedOpen(true);
315 }, []);
316
317 const onPickFirstFrame = useCallback(
318 (file: File) => {
319 if (!FIRST_FRAME_UPLOAD_ENABLED) return;
320 if (referenceImageLocked) {
321 toast.error(t("thread.firstFrame.locked"));
322 return;
323 }
324 setReferencePreviewDismissed(false);
325 void setFirstFrameFile(file);
326 },
327 [referenceImageLocked, setFirstFrameFile, t],
328 );
329
330 const onClearFirstFrame = useCallback(() => {
331 if (referenceImageLocked) {
332 toast.error(t("thread.firstFrame.locked"));
333 return;
334 }
335 clearFirstFrame();
336 setReferencePreviewDismissed(true);
337 uploadedRefImageRef.current = null;
338 if (session?.key) {
339 void deleteReferenceImage(token, session.key)
340 .then(() => onReferenceImageSynced?.())
341 .catch((err) => {
342 if (err instanceof ApiError && err.status === 409) {
343 toast.error(t("thread.firstFrame.locked"));
344 }
345 });
346 }
347 }, [
348 clearFirstFrame,
349 onReferenceImageSynced,
350 referenceImageLocked,
351 session?.key,
352 t,
353 token,
354 ]);
355
356 const onFirstFrameLockedAttempt = useCallback(() => {
357 toast.error(t("thread.firstFrame.locked"));
358 }, [t]);
359
360 useEffect(() => {
361 setAutoGenerate(Boolean(forceAutoGenerate || persistedAutoGenerate));
362 }, [session?.key, forceAutoGenerate, persistedAutoGenerate]);
363
364 useEffect(() => {
365 if (firstFrame || !persistedReferenceImage?.url) {
366 return;
367 }
368 uploadedRefImageRef.current = persistedReferenceImage;
369 }, [firstFrame, persistedReferenceImage]);
370
371 const prevStreamingIdRef = useRef<string | null>(null);
372
373 useEffect(() => {
374 lastPersistKeyRef.current = null;
375 setReferencePreviewDismissed(false);
376 clearFirstFrame();
377 }, [session?.key, clearFirstFrame]);
378
379 useEffect(() => {
380 if (isMockMode || !session?.key) return;
381 let cancelled = false;
382 void (async () => {
383 try {
384 if (pendingDurationSaveRef.current) {
385 await saveGenerationSettings(
386 token,
387 session.key,
388 durationSecRef.current,
389 videoSizeRef.current.width,
390 videoSizeRef.current.height,
391 storyLanguageRef.current,
392 );
393 pendingDurationSaveRef.current = false;
394 return;
395 }
396 const settings = await fetchGenerationSettings(token, session.key);
397 if (!cancelled) {
398 setDurationSec(settings.duration_sec);
399 setVideoSize({ width: settings.width, height: settings.height });
400 setStoryLanguage(parseStoryLanguage(settings.language));
401 }
402 } catch {
403 // keep local value on fetch/save failure
404 }
405 })();
406 return () => {
407 cancelled = true;
408 };
409 }, [session?.key, isMockMode, token]);
410
411 const onDurationChange = useCallback(
412 (sec: number) => {
413 setDurationSec(sec);
414 if (isMockMode) return;
415 if (session?.key) {
416 void saveGenerationSettings(token, session.key, sec).catch(() => {});
417 } else {
418 pendingDurationSaveRef.current = true;
419 }
420 },
421 [isMockMode, session?.key, token],
422 );
423
424 const onVideoSizeChange = useCallback(
425 (size: VideoSize) => {
426 setVideoSize(size);
427 if (isMockMode || videoSizeLocked) return;
428 if (session?.key) {
429 void saveGenerationSettings(
430 token,
431 session.key,
432 durationSecRef.current,
433 size.width,
434 size.height,
435 storyLanguageRef.current,
436 ).catch(() => {});
437 } else {
438 pendingDurationSaveRef.current = true;
439 }
440 },
441 [isMockMode, session?.key, token, videoSizeLocked],
442 );
443
444 const onStoryLanguageChange = useCallback(
445 (language: StoryLanguage) => {
446 setStoryLanguage(language);
447 if (isMockMode) return;
448 if (session?.key) {
449 void saveGenerationSettings(
450 token,
451 session.key,
452 durationSecRef.current,
453 videoSizeRef.current.width,
454 videoSizeRef.current.height,
455 language,
456 ).catch(() => {});
457 } else {
458 pendingDurationSaveRef.current = true;
459 }
460 },
461 [isMockMode, session?.key, token],
462 );
463
464 useEffect(() => {
465 if (!onMessageComplete) return;
466 const last = activeMessages[activeMessages.length - 1];
467 if (last?.role === "assistant" && last.isStreaming) {
468 prevStreamingIdRef.current = last.id;
469 } else if (
470 prevStreamingIdRef.current &&
471 last?.role === "assistant" &&
472 !last.isStreaming &&
473 last.id === prevStreamingIdRef.current
474 ) {
475 prevStreamingIdRef.current = null;
476 onMessageComplete(last);
477 }
478 }, [activeMessages, onMessageComplete]);
479
480 useEffect(() => {
481 if (!chatId) return;
482 const pending = pendingFirstRef.current;
483 if (!pending) return;
484 pendingFirstRef.current = null;
485
486 const ref = uploadedRefImageRef.current;
487 send(pending, undefined, {
488 autoGenerate: autoGenerateRef.current,
489 duration_sec: durationSecRef.current,
490 ...(ref?.url
491 ? {
492 reference_image_url: ref.url,
493 reference_image_name: ref.name,
494 reference_image_width: ref.width,
495 reference_image_height: ref.height,
496 }
497 : {}),
498 });
499 clearFirstFrame();
500 setReferencePreviewDismissed(true);
501 setBooting(false);
502 }, [chatId, clearFirstFrame, send, client]);
503
504 const handleWelcomeSend = useCallback(
505 async (content: string) => {
506 if (booting) return;
507 const frame = firstFrameValueRef.current;
508 if (frame) {
509 try {
510 await prepareStepwiseReference(frame);
511 } catch (error) {
512 showFirstFrameError(error);
513 return false;
514 }
515 }
516 pendingFirstRef.current = content;
517 setBooting(true);
518 const newId = await onNewChat();
519 if (!newId) {
520 pendingFirstRef.current = null;
521 setBooting(false);
522 return false;
523 }
524 return true;
525 },
526 [booting, onNewChat, prepareStepwiseReference, showFirstFrameError],
527 );
528
529 const guardedSessionSend = useCallback(
530 async (content: string, images?: SendImage[]) => {
531 const frame = firstFrameValueRef.current;
532 let ref = uploadedRefImageRef.current;
533 if (frame) {
534 try {
535 ref = await prepareStepwiseReference(frame);
536 } catch (error) {
537 showFirstFrameError(error);
538 return false;
539 }
540 }
541 const outgoingImages = ref?.url
542 ? [
543 ...(images ?? []),
544 {
545 preview: {
546 url: ref.url,
547 name: ref.name || "first-frame.jpg",
548 },
549 },
550 ]
551 : images;
552 send(
553 content,
554 outgoingImages,
555 {
556 autoGenerate: autoGenerateRef.current,
557 duration_sec: durationSecRef.current,
558 ...(ref?.url
559 ? {
560 reference_image_url: ref.url,
561 reference_image_name: ref.name,
562 reference_image_width: ref.width,
563 reference_image_height: ref.height,
564 }
565 : {}),
566 },
567 );
568 if (frame) {
569 clearFirstFrame();
570 setReferencePreviewDismissed(true);
571 }
572 return true;
573 },
574 [
575 clearFirstFrame,
576 prepareStepwiseReference,
577 send,
578 showFirstFrameError,
579 ],
580 );
581
582 const emptyState = loading ? (
583 <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
584 {t("thread.loadingConversation")}
585 </div>
586 ) : modeSelectorSlot ? (
587 modeSelectorSlot
588 ) : (
589 <div className="flex w-full flex-col items-center gap-5 text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
590 <h2
591 className="text-[43px] font-light tracking-[0.02em] text-foreground/80"
592 style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}
593 >
594 Echo Director
595 </h2>
596 </div>
597 );
598
599 const displayFirstFrame =
600 firstFrame ??
601 (!referencePreviewDismissed && persistedReferenceImage?.url
602 ? {
603 previewUrl: persistedReferenceImage.url,
604 croppedBlob: new Blob(),
605 width: persistedReferenceImage.width ?? 0,
606 height: persistedReferenceImage.height ?? 0,
607 name: persistedReferenceImage.name ?? "first-frame.jpg",
608 }
609 : null);
610
611 const stepwisePlaceholder = autoGenerate
612 ? displayFirstFrame
613 ? t("thread.composer.placeholderStepwiseAuto")
614 : t("thread.composer.placeholderStepwiseAutoEmpty")
615 : t("thread.composer.placeholderStepwise");
616
617 const handleAnswerQuestion = useCallback(
618 (messageId: string, cardId: string, value: string) => {
619 const answer = onAnswerQuestion ?? answerQuestion;
620 const target = activeMessages.find((item) => item.id === messageId);
621 const card = target?.questions?.find((item) => item.id === cardId);
622 const intent = classifyFirstFrameQuestion(value, card?.question);
623 void (async () => {
624 if (intent === "confirm_uploaded" || intent === "confirm_edit_done") {
625 const frame = firstFrameValueRef.current;
626 if (frame) {
627 try {
628 await persistStepwiseReference(frame);
629 } catch (error) {
630 showFirstFrameError(error);
631 return;
632 }
633 } else if (
634 intent === "confirm_edit_done" &&
635 persistedReferenceImage?.url &&
636 session?.key
637 ) {
638 try {
639 await deleteReferenceImage(token, session.key);
640 onReferenceImageSynced?.();
641 } catch (err) {
642 if (err instanceof ApiError && err.status === 409) {
643 toast.error(t("thread.firstFrame.locked"));
644 }
645 }
646 }
647 }
648 answer(messageId, cardId, value);
649 })();
650 },
651 [
652 activeMessages,
653 answerQuestion,
654 onAnswerQuestion,
655 onReferenceImageSynced,
656 persistStepwiseReference,
657 persistedReferenceImage?.url,
658 session?.key,
659 showFirstFrameError,
660 t,
661 token,
662 ],
663 );
664
665 const composerShared = {
666 durationSec,
667 onDurationChange,
668 videoSize,
669 onVideoSizeChange,
670 videoSizeLocked,
671 storyLanguage,
672 onStoryLanguageChange,
673 autoGenerate,
674 onAutoGenerateChange: setAutoGenerate,
675 showAutoGenerateToggle: !forceAutoGenerate,
676 durationOptions: LONG_VIDEO_DURATION_OPTIONS,
677 showFirstFrame: showFirstFrameUploader,
678 firstFrame: displayFirstFrame,
679 firstFrameCropping,
680 firstFrameLocked: referenceImageLocked,
681 onFirstFramePick: onPickFirstFrame,
682 onFirstFrameClear: onClearFirstFrame,
683 onFirstFrameLockedAttempt,
684 } as const;
685
686 return (
687 <section
688 className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
689 onDragEnter={showFirstFrameUploader ? onDragEnter : undefined}
690 onDragOver={showFirstFrameUploader ? onDragOver : undefined}
691 onDragLeave={showFirstFrameUploader ? onDragLeave : undefined}
692 onDrop={showFirstFrameUploader ? onDrop : undefined}
693 >
694 <ThreadHeader
695 title={title}
696 onToggleSidebar={onToggleSidebar}
697 onGoHome={onGoHome}
698 hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
699 chatId={chatId ?? undefined}
700 />
701 <ThreadViewport
702 messages={activeMessages}
703 isStreaming={activeStreaming}
704 questionsReady={activeTurnComplete}
705 emptyState={emptyState}
706 actionSlot={isMockMode ? actionSlot : undefined}
707 hideComposer={!!modeSelectorSlot && activeMessages.length === 0}
708 onAnswerQuestion={handleAnswerQuestion}
709 onMemoryReviewAction={onMemoryReviewAction}
710 memoryAssets={memoryAssets}
711 onCreateMemoryAsset={onCreateMemoryAsset}
712 getNextContinuous={getNextContinuous}
713 composer={
714 <>
715 {!isMockMode && streamError ? (
716 <StreamErrorNotice
717 error={streamError}
718 onDismiss={dismissStreamError}
719 />
720 ) : null}
721 {isMockMode ? (
722 <ThreadComposer
723 onSend={activeSend}
724 disabled={false}
725 sendDisabled={composerSendDisabled || !activeTurnComplete}
726 placeholder={stepwisePlaceholder}
727 modelLabel={toModelBadgeLabel(modelName)}
728 variant={showHero ? "hero" : "thread"}
729 {...composerShared}
730 showFirstFrame={false}
731 />
732 ) : session ? (
733 <ThreadComposer
734 onSend={guardedSessionSend}
735 disabled={!chatId}
736 sendDisabled={composerSendDisabled || !activeTurnComplete}
737 placeholder={stepwisePlaceholder}
738 modelLabel={toModelBadgeLabel(modelName)}
739 variant={showHeroComposer ? "hero" : "thread"}
740 {...composerShared}
741 />
742 ) : (
743 <ThreadComposer
744 onSend={handleWelcomeSend}
745 disabled={booting}
746 sendDisabled={composerSendDisabled || !activeTurnComplete}
747 placeholder={
748 booting
749 ? t("thread.composer.placeholderOpening")
750 : stepwisePlaceholder
751 }
752 modelLabel={toModelBadgeLabel(modelName)}
753 variant="hero"
754 {...composerShared}
755 />
756 )}
757 </>
758 }
759 />
760 {showFirstFrameUploader && isDragging ? (
761 <div
762 className={cn(
763 "pointer-events-none absolute inset-0 z-40 flex items-center justify-center",
764 "bg-background/70 backdrop-blur-[2px]",
765 )}
766 aria-hidden
767 >
768 <div
769 className={cn(
770 "rounded-2xl border-2 border-dashed border-foreground/25",
771 "bg-background/90 px-8 py-6 text-sm font-medium text-foreground/70",
772 )}
773 >
774 {t("thread.firstFrame.dropHint")}
775 </div>
776 </div>
777 ) : null}
778 <AlertDialog
779 open={firstFrameUploadFailedOpen}
780 onOpenChange={(open) => {
781 if (!open) setFirstFrameUploadFailedOpen(false);
782 }}
783 >
784 <AlertDialogContent className="max-w-sm">
785 <AlertDialogHeader>
786 <AlertDialogTitle className="text-[15px]">
787 {t("thread.firstFrame.uploadFailed")}
788 </AlertDialogTitle>
789 <AlertDialogDescription>
790 {firstFrameUploadError || t("thread.firstFrame.uploadFailed")}
791 </AlertDialogDescription>
792 </AlertDialogHeader>
793 <AlertDialogFooter className="mt-2">
794 <AlertDialogAction
795 onClick={() => setFirstFrameUploadFailedOpen(false)}
796 className="h-8 rounded-lg px-3 text-[13px]"
797 >
798 {t("thread.firstFrame.uploadFailedOk")}
799 </AlertDialogAction>
800 </AlertDialogFooter>
801 </AlertDialogContent>
802 </AlertDialog>
803 </section>
804 );
805 }
806
807 function ThreadShellConnected(props: Omit<ThreadShellProps, "stream">) {
808 const stream = useThreadStream(props.session, {
809 onReplyEnd: props.onReplyEnd,
810 });
811 return <ThreadShellInner {...props} stream={stream} />;
812 }
813
814 export function ThreadShell({ stream, ...props }: ThreadShellProps) {
815 if (stream) {
816 return <ThreadShellInner {...props} stream={stream} />;
817 }
818 return <ThreadShellConnected {...props} />;
819 }
820
820 lines Plain Text