@love-moon/app-sdk 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -113,6 +113,8 @@ function reducer(state, action) {
113
113
  return { ...state, error: action.error };
114
114
  case "TASK_FINISHED":
115
115
  return { ...state, runtime: null };
116
+ case "RESET_TASK":
117
+ return { ...INITIAL_STATE, connectionState: state.connectionState, loadingHistory: true };
116
118
  default:
117
119
  return state;
118
120
  }
@@ -197,7 +199,7 @@ function ChatProvider(props) {
197
199
  void runCatchUp();
198
200
  }, delayMs);
199
201
  };
200
- dispatch({ type: "LOADING_HISTORY", loading: true });
202
+ dispatch({ type: "RESET_TASK" });
201
203
  activeAdapter.fetchHistory(taskId, { signal: abort.signal }).then((page) => {
202
204
  if (cancelled) return;
203
205
  dispatch({
@@ -336,7 +338,28 @@ function ChatProvider(props) {
336
338
  dispatch({ type: "LOADING_HISTORY", loading: false });
337
339
  }
338
340
  };
339
- return { state, taskId, adapter, send, interrupt, loadEarlier };
341
+ const restart = async (opts) => {
342
+ const fn = adapterRef.current.restart;
343
+ if (!fn) return;
344
+ try {
345
+ await fn(taskIdRef.current, opts);
346
+ } catch (err) {
347
+ onErrorRef.current?.(err);
348
+ dispatch({ type: "SET_ERROR", error: extractError(err) });
349
+ }
350
+ };
351
+ return {
352
+ state,
353
+ taskId,
354
+ adapter,
355
+ send,
356
+ interrupt,
357
+ loadEarlier,
358
+ restart,
359
+ // `adapter` is in the dep array, so this re-derives whenever the adapter
360
+ // reference changes (the next subscribe also picks up the change).
361
+ restartSupported: typeof adapter.restart === "function"
362
+ };
340
363
  }, [state, taskId, adapter]);
341
364
  return /* @__PURE__ */ jsx(ChatContext.Provider, { value, children: props.children });
342
365
  }
@@ -360,128 +383,1049 @@ function extractError(err) {
360
383
  }
361
384
 
362
385
  // src/react/components/MessageList.tsx
363
- import { useEffect as useEffect2, useLayoutEffect, useRef as useRef2 } from "react";
386
+ import {
387
+ useCallback,
388
+ useEffect as useEffect4,
389
+ useLayoutEffect,
390
+ useMemo as useMemo2,
391
+ useRef as useRef4,
392
+ useState as useState2
393
+ } from "react";
394
+
395
+ // src/react/components/MessageBubble.tsx
396
+ import { useEffect as useEffect2, useRef as useRef2, useState } from "react";
364
397
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
365
- function MessageList({ labels, renderMessageContent }) {
366
- const { state, loadEarlier } = useChat();
367
- const containerRef = useRef2(null);
368
- const lastMessageCountRef = useRef2(state.messages.length);
369
- useLayoutEffect(() => {
398
+ var TOUCH_DOUBLE_TAP_MS = 320;
399
+ var isInteractiveTarget = (target) => target instanceof HTMLElement && Boolean(target.closest("a, button, audio, video, summary, input, textarea, select"));
400
+ var prefersTapTimestamp = () => {
401
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
402
+ return false;
403
+ }
404
+ return window.matchMedia("(hover: none), (pointer: coarse)").matches;
405
+ };
406
+ var formatTime = (dateStr) => {
407
+ if (!dateStr) return "";
408
+ const date = new Date(dateStr);
409
+ if (Number.isNaN(date.getTime())) return "";
410
+ return date.toLocaleString(void 0, {
411
+ month: "2-digit",
412
+ day: "2-digit",
413
+ hour: "2-digit",
414
+ minute: "2-digit"
415
+ });
416
+ };
417
+ var formatBytes = (value) => {
418
+ if (!Number.isFinite(value) || value < 1024) {
419
+ return `${Math.max(0, Math.round(value || 0))} B`;
420
+ }
421
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
422
+ if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`;
423
+ return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`;
424
+ };
425
+ var attachmentKind = (att) => {
426
+ const mime = (att.mimeType || "").toLowerCase();
427
+ if (mime.startsWith("image/")) return "image";
428
+ if (mime.startsWith("video/")) return "video";
429
+ if (mime.startsWith("audio/")) return "audio";
430
+ return "file";
431
+ };
432
+ var getAppOriginName = (message) => {
433
+ const metadata = message.metadata;
434
+ if (!metadata || typeof metadata !== "object") return null;
435
+ const audit = metadata.audit;
436
+ if (!audit || typeof audit !== "object") return null;
437
+ const actor = audit.actor;
438
+ if (actor !== "app") return null;
439
+ const name = audit.appDisplayName;
440
+ return typeof name === "string" && name.trim() ? name.trim() : "app";
441
+ };
442
+ function MessageBubble({
443
+ message,
444
+ renderMessageContent,
445
+ onRequestMenu,
446
+ showAppOriginChip = false
447
+ }) {
448
+ const lastTouchEndAtRef = useRef2(0);
449
+ const [timestampVisible, setTimestampVisible] = useState(false);
450
+ useEffect2(() => {
451
+ setTimestampVisible(false);
452
+ }, [message.id]);
453
+ const content = renderMessageContent ? renderMessageContent(message) : message.content;
454
+ const attachments = message.attachments ?? [];
455
+ const appOriginName = showAppOriginChip ? getAppOriginName(message) : null;
456
+ const timeText = formatTime(message.createdAt);
457
+ return /* @__PURE__ */ jsxs(
458
+ "div",
459
+ {
460
+ className: "conductor-bubble-group" + (timestampVisible ? " conductor-bubble-group--timestamp-visible" : ""),
461
+ children: [
462
+ timeText ? /* @__PURE__ */ jsx2("span", { className: "conductor-bubble__time", children: timeText }) : null,
463
+ /* @__PURE__ */ jsxs(
464
+ "div",
465
+ {
466
+ className: "conductor-bubble",
467
+ role: "button",
468
+ tabIndex: 0,
469
+ onClick: (event) => {
470
+ if (isInteractiveTarget(event.target)) return;
471
+ if (prefersTapTimestamp()) {
472
+ setTimestampVisible((current) => !current);
473
+ }
474
+ },
475
+ onDoubleClick: (event) => {
476
+ if (isInteractiveTarget(event.target)) return;
477
+ onRequestMenu(message);
478
+ },
479
+ onTouchEnd: (event) => {
480
+ if (isInteractiveTarget(event.target)) {
481
+ lastTouchEndAtRef.current = 0;
482
+ return;
483
+ }
484
+ const now = Date.now();
485
+ if (now - lastTouchEndAtRef.current <= TOUCH_DOUBLE_TAP_MS) {
486
+ lastTouchEndAtRef.current = 0;
487
+ event.preventDefault();
488
+ onRequestMenu(message);
489
+ return;
490
+ }
491
+ lastTouchEndAtRef.current = now;
492
+ },
493
+ onKeyDown: (event) => {
494
+ if (event.key === "Enter" || event.key === " ") {
495
+ event.preventDefault();
496
+ onRequestMenu(message);
497
+ }
498
+ },
499
+ children: [
500
+ appOriginName ? /* @__PURE__ */ jsxs("span", { className: "conductor-bubble__origin", children: [
501
+ "via ",
502
+ appOriginName
503
+ ] }) : null,
504
+ content
505
+ ]
506
+ }
507
+ ),
508
+ attachments.length > 0 ? /* @__PURE__ */ jsx2("div", { className: "conductor-bubble__attachments", children: attachments.map((att) => {
509
+ const kind = attachmentKind(att);
510
+ return /* @__PURE__ */ jsxs("div", { className: "conductor-attachment", "data-kind": kind, children: [
511
+ kind === "image" ? /* @__PURE__ */ jsx2(
512
+ "a",
513
+ {
514
+ href: att.url,
515
+ target: "_blank",
516
+ rel: "noreferrer",
517
+ className: "conductor-attachment__media-link",
518
+ children: /* @__PURE__ */ jsx2("img", { src: att.url, alt: att.filename, className: "conductor-attachment__image" })
519
+ }
520
+ ) : null,
521
+ kind === "video" ? /* @__PURE__ */ jsx2("video", { controls: true, preload: "metadata", className: "conductor-attachment__video", src: att.url }) : null,
522
+ kind === "audio" ? /* @__PURE__ */ jsx2("audio", { controls: true, className: "conductor-attachment__audio", src: att.url }) : null,
523
+ kind === "file" ? /* @__PURE__ */ jsxs("a", { href: att.url, target: "_blank", rel: "noreferrer", className: "conductor-attachment__file", children: [
524
+ /* @__PURE__ */ jsx2("span", { className: "conductor-attachment__name", children: att.filename }),
525
+ /* @__PURE__ */ jsx2("span", { className: "conductor-attachment__size", children: formatBytes(att.sizeBytes) })
526
+ ] }) : /* @__PURE__ */ jsxs("div", { className: "conductor-attachment__meta", children: [
527
+ /* @__PURE__ */ jsx2("span", { className: "conductor-attachment__name", children: att.filename }),
528
+ /* @__PURE__ */ jsx2("span", { className: "conductor-attachment__size", children: formatBytes(att.sizeBytes) })
529
+ ] })
530
+ ] }, att.id);
531
+ }) }) : null
532
+ ]
533
+ }
534
+ );
535
+ }
536
+
537
+ // src/react/components/QuestionNav.tsx
538
+ import { useEffect as useEffect3, useRef as useRef3 } from "react";
539
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
540
+ function QuestionNav({
541
+ count,
542
+ activeIndex,
543
+ onJump,
544
+ visible = true,
545
+ label = "Jump to question"
546
+ }) {
547
+ const buttonRefs = useRef3(/* @__PURE__ */ new Map());
548
+ useEffect3(() => {
549
+ if (!visible) return;
550
+ const node = buttonRefs.current.get(activeIndex);
551
+ if (!node || typeof node.scrollIntoView !== "function") return;
552
+ node.scrollIntoView({ block: "nearest", inline: "nearest" });
553
+ }, [activeIndex, visible, count]);
554
+ if (count === 0) return null;
555
+ const visibilityClass = visible ? "conductor-question-nav--visible" : "conductor-question-nav--hidden";
556
+ return /* @__PURE__ */ jsx3(
557
+ "nav",
558
+ {
559
+ "aria-label": label,
560
+ "aria-hidden": !visible,
561
+ className: `conductor-question-nav ${visibilityClass}`,
562
+ children: Array.from({ length: count }, (_, i) => /* @__PURE__ */ jsxs2("div", { className: "conductor-question-nav__group", children: [
563
+ i > 0 && /* @__PURE__ */ jsx3("span", { className: "conductor-question-nav__sep", "aria-hidden": "true" }),
564
+ /* @__PURE__ */ jsx3(
565
+ "button",
566
+ {
567
+ type: "button",
568
+ ref: (node) => {
569
+ if (node) {
570
+ buttonRefs.current.set(i, node);
571
+ } else {
572
+ buttonRefs.current.delete(i);
573
+ }
574
+ },
575
+ tabIndex: visible ? 0 : -1,
576
+ "aria-label": `${label} ${i + 1}`,
577
+ title: `${i + 1}`,
578
+ onClick: () => onJump(i),
579
+ className: "conductor-question-nav__btn",
580
+ children: /* @__PURE__ */ jsx3(
581
+ "span",
582
+ {
583
+ className: "conductor-question-nav__dot" + (activeIndex === i ? " conductor-question-nav__dot--active" : "")
584
+ }
585
+ )
586
+ }
587
+ )
588
+ ] }, i))
589
+ }
590
+ );
591
+ }
592
+
593
+ // src/react/components/MessageList.tsx
594
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
595
+ var SCROLL_STORAGE_PREFIX = "conductor-sdk-task-scroll:";
596
+ var SCROLL_BOTTOM_THRESHOLD_PX = 40;
597
+ var SCROLL_TOP_LOAD_THRESHOLD_PX = 24;
598
+ var SCROLL_TO_BOTTOM_BUTTON_MIN_OVERFLOW_PX = 40;
599
+ var SCROLL_DIRECTION_THRESHOLD_PX = 4;
600
+ var QUESTION_ACTIVE_OFFSET_PX = 80;
601
+ var QUESTION_JUMP_TOP_PADDING_PX = 12;
602
+ var COPY_FEEDBACK_MS = 1500;
603
+ var SCROLL_PERSIST_DEBOUNCE_MS = 150;
604
+ var getScrollStorageKey = (taskId) => `${SCROLL_STORAGE_PREFIX}${taskId}`;
605
+ var getMaxScrollTop = (el) => Math.max(0, el.scrollHeight - el.clientHeight);
606
+ var clampScrollTop = (el, scrollTop) => Math.min(Math.max(scrollTop, 0), getMaxScrollTop(el));
607
+ var isNearBottom = (el) => getMaxScrollTop(el) - el.scrollTop <= SCROLL_BOTTOM_THRESHOLD_PX;
608
+ var isUserSide = (m) => m.role === "user" || m.role === "sdk";
609
+ var readStoredScrollState = (taskId) => {
610
+ if (typeof window === "undefined") return null;
611
+ try {
612
+ const raw = window.sessionStorage.getItem(getScrollStorageKey(taskId));
613
+ if (!raw) return null;
614
+ const parsed = JSON.parse(raw);
615
+ if (typeof parsed === "object" && parsed !== null && Number.isFinite(parsed.scrollTop) && typeof parsed.stickToBottom === "boolean") {
616
+ return { scrollTop: Math.max(0, parsed.scrollTop), stickToBottom: parsed.stickToBottom };
617
+ }
618
+ } catch {
619
+ }
620
+ return null;
621
+ };
622
+ var writeStoredScrollState = (taskId, state) => {
623
+ if (typeof window === "undefined") return;
624
+ try {
625
+ window.sessionStorage.setItem(getScrollStorageKey(taskId), JSON.stringify(state));
626
+ } catch {
627
+ }
628
+ };
629
+ async function copyText(text) {
630
+ try {
631
+ if (navigator.clipboard && window.isSecureContext) {
632
+ await navigator.clipboard.writeText(text);
633
+ return true;
634
+ }
635
+ } catch {
636
+ }
637
+ try {
638
+ const textarea = document.createElement("textarea");
639
+ textarea.value = text;
640
+ textarea.setAttribute("readonly", "");
641
+ textarea.style.position = "fixed";
642
+ textarea.style.top = "-1000px";
643
+ textarea.style.left = "-1000px";
644
+ document.body.appendChild(textarea);
645
+ textarea.select();
646
+ const ok = typeof document.execCommand === "function" && document.execCommand("copy");
647
+ document.body.removeChild(textarea);
648
+ return Boolean(ok);
649
+ } catch {
650
+ return false;
651
+ }
652
+ }
653
+ function MessageList({
654
+ labels,
655
+ renderMessageContent,
656
+ showAppOriginChip,
657
+ readOnly = false
658
+ }) {
659
+ const { state, loadEarlier, send, interrupt, restart, restartSupported, taskId } = useChat();
660
+ const containerRef = useRef4(null);
661
+ const [showScrollToBottom, setShowScrollToBottom] = useState2(false);
662
+ const [showQuestionNav, setShowQuestionNav] = useState2(false);
663
+ const [activeQuestion, setActiveQuestion] = useState2(0);
664
+ const [menuMessage, setMenuMessage] = useState2(null);
665
+ const [copied, setCopied] = useState2(false);
666
+ const [restartPending, setRestartPending] = useState2(false);
667
+ const questionRefs = useRef4(/* @__PURE__ */ new Map());
668
+ const isJumpingRef = useRef4(false);
669
+ const lastScrollTopRef = useRef4(0);
670
+ const activeQuestionRafRef = useRef4(null);
671
+ const copyTimeoutRef = useRef4(null);
672
+ const scrollWriteTimerRef = useRef4(null);
673
+ const pendingScrollStateRef = useRef4(null);
674
+ const previousMessageCountRef = useRef4(state.messages.length);
675
+ const pendingRestoreScrollStateRef = useRef4(null);
676
+ const pendingPrependAnchorRef = useRef4(null);
677
+ const autoLoadUntilFilledRef = useRef4(false);
678
+ const shouldRestoreScrollRef = useRef4(true);
679
+ const shouldStickToBottomRef = useRef4(true);
680
+ const messages = state.messages;
681
+ const hasMoreBefore = state.hasMoreBefore;
682
+ const oldestMessageId = state.oldestMessageId;
683
+ const loadingHistory = state.loadingHistory;
684
+ const replyInProgress = state.runtime?.replyInProgress === true;
685
+ const canInterrupt = replyInProgress && Boolean(state.latestReplyId);
686
+ const questionIndexById = useMemo2(() => {
687
+ const map = /* @__PURE__ */ new Map();
688
+ let q = 0;
689
+ for (const m of messages) {
690
+ if (isUserSide(m)) {
691
+ map.set(m.id, q);
692
+ q += 1;
693
+ }
694
+ }
695
+ return map;
696
+ }, [messages]);
697
+ const userQuestionCount = questionIndexById.size;
698
+ const writeScrollNow = useCallback(
699
+ (override) => {
700
+ if (scrollWriteTimerRef.current !== null) {
701
+ window.clearTimeout(scrollWriteTimerRef.current);
702
+ scrollWriteTimerRef.current = null;
703
+ }
704
+ const el = containerRef.current;
705
+ const next = override ?? pendingScrollStateRef.current ?? (el ? { scrollTop: clampScrollTop(el, el.scrollTop), stickToBottom: isNearBottom(el) } : null);
706
+ pendingScrollStateRef.current = null;
707
+ if (next) writeStoredScrollState(taskId, next);
708
+ },
709
+ [taskId]
710
+ );
711
+ const persistScroll = useCallback(
712
+ (scrollTop) => {
713
+ const el = containerRef.current;
714
+ if (!el) return;
715
+ const next = typeof scrollTop === "number" ? clampScrollTop(el, scrollTop) : clampScrollTop(el, el.scrollTop);
716
+ const stick = isNearBottom(el);
717
+ const canScroll = getMaxScrollTop(el) > SCROLL_TO_BOTTOM_BUTTON_MIN_OVERFLOW_PX;
718
+ shouldStickToBottomRef.current = stick;
719
+ setShowScrollToBottom(canScroll && !stick);
720
+ pendingScrollStateRef.current = { scrollTop: next, stickToBottom: stick };
721
+ if (scrollWriteTimerRef.current === null) {
722
+ scrollWriteTimerRef.current = window.setTimeout(() => {
723
+ scrollWriteTimerRef.current = null;
724
+ if (pendingScrollStateRef.current) {
725
+ writeStoredScrollState(taskId, pendingScrollStateRef.current);
726
+ pendingScrollStateRef.current = null;
727
+ }
728
+ }, SCROLL_PERSIST_DEBOUNCE_MS);
729
+ }
730
+ },
731
+ [taskId]
732
+ );
733
+ const scrollToBottom = useCallback(() => {
370
734
  const el = containerRef.current;
371
735
  if (!el) return;
372
- const grew = state.messages.length > lastMessageCountRef.current;
373
- lastMessageCountRef.current = state.messages.length;
374
- if (!grew) return;
375
- const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
376
- if (distanceFromBottom < 120) {
377
- el.scrollTop = el.scrollHeight;
378
- }
379
- }, [state.messages.length]);
380
- useEffect2(() => {
736
+ const next = getMaxScrollTop(el);
737
+ lastScrollTopRef.current = next;
738
+ el.scrollTop = next;
739
+ shouldStickToBottomRef.current = true;
740
+ setShowScrollToBottom(false);
741
+ setShowQuestionNav(false);
742
+ writeScrollNow({ scrollTop: next, stickToBottom: true });
743
+ }, [writeScrollNow]);
744
+ const handleJumpToQuestion = useCallback((questionIndex) => {
745
+ const node = questionRefs.current.get(questionIndex);
746
+ const el = containerRef.current;
747
+ if (!node || !el) return;
748
+ isJumpingRef.current = true;
749
+ const containerTop = el.getBoundingClientRect().top;
750
+ const elTop = node.getBoundingClientRect().top;
751
+ const next = clampScrollTop(
752
+ el,
753
+ el.scrollTop + (elTop - containerTop) - QUESTION_JUMP_TOP_PADDING_PX
754
+ );
755
+ lastScrollTopRef.current = next;
756
+ el.scrollTop = next;
757
+ setActiveQuestion(questionIndex);
758
+ window.setTimeout(() => {
759
+ isJumpingRef.current = false;
760
+ }, 120);
761
+ }, []);
762
+ const loadOlder = useCallback(
763
+ async (options) => {
764
+ if (!oldestMessageId || loadingHistory) return;
765
+ if (options?.continueUntilFilled) autoLoadUntilFilledRef.current = true;
766
+ const el = containerRef.current;
767
+ if (el) {
768
+ pendingPrependAnchorRef.current = {
769
+ previousScrollHeight: el.scrollHeight,
770
+ previousScrollTop: el.scrollTop
771
+ };
772
+ }
773
+ await loadEarlier();
774
+ },
775
+ [oldestMessageId, loadingHistory, loadEarlier]
776
+ );
777
+ const maybeContinueAutoLoad = useCallback(() => {
778
+ const el = containerRef.current;
779
+ if (!autoLoadUntilFilledRef.current || !el || loadingHistory) return;
780
+ if (!hasMoreBefore || !oldestMessageId) {
781
+ autoLoadUntilFilledRef.current = false;
782
+ return;
783
+ }
784
+ if (el.scrollHeight > el.clientHeight + SCROLL_TOP_LOAD_THRESHOLD_PX) {
785
+ autoLoadUntilFilledRef.current = false;
786
+ return;
787
+ }
788
+ void loadOlder({ continueUntilFilled: true });
789
+ }, [loadingHistory, hasMoreBefore, oldestMessageId, loadOlder]);
790
+ const handleScroll = useCallback(() => {
791
+ persistScroll();
792
+ const el = containerRef.current;
793
+ if (!el) return;
794
+ const current = el.scrollTop;
795
+ const delta = current - lastScrollTopRef.current;
796
+ if (!isJumpingRef.current && userQuestionCount > 1) {
797
+ if (delta <= -SCROLL_DIRECTION_THRESHOLD_PX) {
798
+ setShowQuestionNav(true);
799
+ } else if (delta >= SCROLL_DIRECTION_THRESHOLD_PX) {
800
+ setShowQuestionNav(false);
801
+ }
802
+ }
803
+ if (!isJumpingRef.current && questionRefs.current.size > 0 && activeQuestionRafRef.current === null) {
804
+ activeQuestionRafRef.current = window.requestAnimationFrame(() => {
805
+ activeQuestionRafRef.current = null;
806
+ if (isJumpingRef.current) return;
807
+ const c = containerRef.current;
808
+ if (!c) return;
809
+ const containerTop = c.getBoundingClientRect().top;
810
+ let closest = 0;
811
+ let closestDist = Infinity;
812
+ questionRefs.current.forEach((node, idx) => {
813
+ const dist = Math.abs(
814
+ node.getBoundingClientRect().top - containerTop - QUESTION_ACTIVE_OFFSET_PX
815
+ );
816
+ if (dist < closestDist) {
817
+ closestDist = dist;
818
+ closest = idx;
819
+ }
820
+ });
821
+ setActiveQuestion((cur) => cur === closest ? cur : closest);
822
+ });
823
+ }
824
+ lastScrollTopRef.current = current;
825
+ if (!hasMoreBefore || loadingHistory || !oldestMessageId) {
826
+ if (!hasMoreBefore || !oldestMessageId) autoLoadUntilFilledRef.current = false;
827
+ return;
828
+ }
829
+ if (current <= SCROLL_TOP_LOAD_THRESHOLD_PX) {
830
+ void loadOlder({ continueUntilFilled: true });
831
+ return;
832
+ }
833
+ autoLoadUntilFilledRef.current = false;
834
+ }, [persistScroll, userQuestionCount, hasMoreBefore, loadingHistory, oldestMessageId, loadOlder]);
835
+ useLayoutEffect(() => {
836
+ pendingRestoreScrollStateRef.current = readStoredScrollState(taskId);
837
+ pendingPrependAnchorRef.current = null;
838
+ autoLoadUntilFilledRef.current = false;
839
+ shouldRestoreScrollRef.current = true;
840
+ shouldStickToBottomRef.current = true;
841
+ previousMessageCountRef.current = messages.length;
842
+ questionRefs.current = /* @__PURE__ */ new Map();
843
+ isJumpingRef.current = false;
844
+ lastScrollTopRef.current = 0;
845
+ if (activeQuestionRafRef.current !== null) {
846
+ window.cancelAnimationFrame(activeQuestionRafRef.current);
847
+ activeQuestionRafRef.current = null;
848
+ }
849
+ setShowScrollToBottom(false);
850
+ setShowQuestionNav(false);
851
+ setActiveQuestion(0);
852
+ setMenuMessage(null);
853
+ setCopied(false);
854
+ }, [taskId]);
855
+ useLayoutEffect(() => {
381
856
  const el = containerRef.current;
382
857
  if (!el) return;
383
- el.scrollTop = el.scrollHeight;
858
+ if (pendingPrependAnchorRef.current) {
859
+ const { previousScrollHeight, previousScrollTop } = pendingPrependAnchorRef.current;
860
+ const delta = el.scrollHeight - previousScrollHeight;
861
+ const next = clampScrollTop(el, previousScrollTop + delta);
862
+ lastScrollTopRef.current = next;
863
+ el.scrollTop = next;
864
+ persistScroll(next);
865
+ pendingPrependAnchorRef.current = null;
866
+ previousMessageCountRef.current = messages.length;
867
+ maybeContinueAutoLoad();
868
+ return;
869
+ }
870
+ if (shouldRestoreScrollRef.current) {
871
+ const belongsToTask = messages.length === 0 || messages[0].taskId === taskId;
872
+ if (!belongsToTask) return;
873
+ if (loadingHistory && messages.length === 0) return;
874
+ const stored = pendingRestoreScrollStateRef.current;
875
+ if (stored?.stickToBottom) {
876
+ scrollToBottom();
877
+ } else if (stored) {
878
+ const next = clampScrollTop(el, stored.scrollTop);
879
+ lastScrollTopRef.current = next;
880
+ el.scrollTop = next;
881
+ persistScroll(next);
882
+ } else {
883
+ scrollToBottom();
884
+ }
885
+ shouldRestoreScrollRef.current = false;
886
+ pendingRestoreScrollStateRef.current = null;
887
+ previousMessageCountRef.current = messages.length;
888
+ maybeContinueAutoLoad();
889
+ return;
890
+ }
891
+ const prevCount = previousMessageCountRef.current;
892
+ if (messages.length > prevCount) {
893
+ const lastMsg = messages[messages.length - 1];
894
+ const localSend = lastMsg ? isUserSide(lastMsg) && lastMsg.id.startsWith("pending:") : false;
895
+ if (shouldStickToBottomRef.current || localSend) {
896
+ scrollToBottom();
897
+ } else {
898
+ persistScroll();
899
+ }
900
+ } else {
901
+ persistScroll();
902
+ }
903
+ previousMessageCountRef.current = messages.length;
904
+ maybeContinueAutoLoad();
905
+ }, [loadingHistory, messages.length, taskId, persistScroll, scrollToBottom, maybeContinueAutoLoad]);
906
+ useEffect4(
907
+ () => () => {
908
+ writeScrollNow();
909
+ },
910
+ [taskId, writeScrollNow]
911
+ );
912
+ useEffect4(
913
+ () => () => {
914
+ if (activeQuestionRafRef.current !== null) {
915
+ window.cancelAnimationFrame(activeQuestionRafRef.current);
916
+ activeQuestionRafRef.current = null;
917
+ }
918
+ if (copyTimeoutRef.current !== null) {
919
+ window.clearTimeout(copyTimeoutRef.current);
920
+ copyTimeoutRef.current = null;
921
+ }
922
+ if (scrollWriteTimerRef.current !== null) {
923
+ window.clearTimeout(scrollWriteTimerRef.current);
924
+ scrollWriteTimerRef.current = null;
925
+ }
926
+ },
927
+ []
928
+ );
929
+ const closeMenu = useCallback(() => {
930
+ if (copyTimeoutRef.current !== null) {
931
+ window.clearTimeout(copyTimeoutRef.current);
932
+ copyTimeoutRef.current = null;
933
+ }
934
+ setCopied(false);
935
+ setMenuMessage(null);
936
+ }, []);
937
+ const openMenu = useCallback((message) => {
938
+ if (copyTimeoutRef.current !== null) {
939
+ window.clearTimeout(copyTimeoutRef.current);
940
+ copyTimeoutRef.current = null;
941
+ }
942
+ setCopied(false);
943
+ setMenuMessage(message);
384
944
  }, []);
385
- return /* @__PURE__ */ jsxs("div", { ref: containerRef, className: "conductor-message-list", role: "log", "aria-live": "polite", children: [
386
- state.hasMoreBefore && /* @__PURE__ */ jsx2("div", { className: "conductor-load-earlier", children: /* @__PURE__ */ jsx2(
945
+ useEffect4(() => {
946
+ if (!menuMessage) return;
947
+ const onKeyDown = (event) => {
948
+ if (event.key === "Escape") closeMenu();
949
+ };
950
+ window.addEventListener("keydown", onKeyDown);
951
+ return () => window.removeEventListener("keydown", onKeyDown);
952
+ }, [menuMessage, closeMenu]);
953
+ const handleCopy = useCallback(async () => {
954
+ if (!menuMessage) return;
955
+ const ok = await copyText(menuMessage.content);
956
+ if (!ok) {
957
+ closeMenu();
958
+ return;
959
+ }
960
+ setCopied(true);
961
+ copyTimeoutRef.current = window.setTimeout(() => {
962
+ closeMenu();
963
+ }, COPY_FEEDBACK_MS);
964
+ }, [menuMessage, closeMenu]);
965
+ const handleResend = useCallback(() => {
966
+ if (!menuMessage || !menuMessage.content.trim()) return;
967
+ void send(menuMessage.content);
968
+ closeMenu();
969
+ }, [menuMessage, send, closeMenu]);
970
+ const handleInterrupt = useCallback(() => {
971
+ void interrupt();
972
+ closeMenu();
973
+ }, [interrupt, closeMenu]);
974
+ const handleRestart = useCallback(async () => {
975
+ if (!restartSupported || restartPending) return;
976
+ setRestartPending(true);
977
+ closeMenu();
978
+ try {
979
+ await restart({ restartMode: "refresh_session" });
980
+ } finally {
981
+ setRestartPending(false);
982
+ }
983
+ }, [restartSupported, restartPending, restart, closeMenu]);
984
+ const menuIsUserSide = menuMessage ? isUserSide(menuMessage) : false;
985
+ const showEmptyState = messages.length === 0 && !loadingHistory;
986
+ return /* @__PURE__ */ jsxs3("div", { className: "conductor-message-list-viewport", children: [
987
+ /* @__PURE__ */ jsxs3(
988
+ "div",
989
+ {
990
+ ref: containerRef,
991
+ className: "conductor-message-list",
992
+ role: "log",
993
+ "aria-live": "polite",
994
+ onScroll: handleScroll,
995
+ children: [
996
+ hasMoreBefore && /* @__PURE__ */ jsx4("div", { className: "conductor-load-earlier", children: /* @__PURE__ */ jsx4(
997
+ "button",
998
+ {
999
+ type: "button",
1000
+ onClick: () => {
1001
+ void loadOlder({ continueUntilFilled: true });
1002
+ },
1003
+ disabled: loadingHistory,
1004
+ children: loadingHistory ? "\u2026" : labels.loadEarlier
1005
+ }
1006
+ ) }),
1007
+ showEmptyState ? /* @__PURE__ */ jsxs3("div", { className: "conductor-empty", children: [
1008
+ /* @__PURE__ */ jsx4(
1009
+ "svg",
1010
+ {
1011
+ className: "conductor-empty__icon",
1012
+ fill: "none",
1013
+ stroke: "currentColor",
1014
+ strokeWidth: 1.5,
1015
+ viewBox: "0 0 24 24",
1016
+ "aria-hidden": "true",
1017
+ children: /* @__PURE__ */ jsx4(
1018
+ "path",
1019
+ {
1020
+ strokeLinecap: "round",
1021
+ strokeLinejoin: "round",
1022
+ d: "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
1023
+ }
1024
+ )
1025
+ }
1026
+ ),
1027
+ /* @__PURE__ */ jsx4("p", { className: "conductor-empty__title", children: labels.emptyTitle }),
1028
+ /* @__PURE__ */ jsx4("p", { className: "conductor-empty__body", children: labels.emptyBody }),
1029
+ restartSupported && !readOnly ? /* @__PURE__ */ jsx4(
1030
+ "button",
1031
+ {
1032
+ type: "button",
1033
+ className: "conductor-button conductor-empty__restart",
1034
+ "data-testid": "conductor-empty-restart",
1035
+ disabled: restartPending,
1036
+ onClick: () => {
1037
+ void handleRestart();
1038
+ },
1039
+ children: restartPending ? labels.restartPending : labels.restart
1040
+ }
1041
+ ) : null
1042
+ ] }) : null,
1043
+ messages.map((m) => {
1044
+ const isUser = isUserSide(m);
1045
+ const isPending = m.id.startsWith("pending:");
1046
+ const qIdx = questionIndexById.get(m.id);
1047
+ return /* @__PURE__ */ jsx4(
1048
+ "div",
1049
+ {
1050
+ ref: (node) => {
1051
+ if (qIdx == null) return;
1052
+ if (node) {
1053
+ questionRefs.current.set(qIdx, node);
1054
+ } else {
1055
+ questionRefs.current.delete(qIdx);
1056
+ }
1057
+ },
1058
+ className: "conductor-message " + (isUser ? "conductor-message--user" : "conductor-message--assistant") + (isPending ? " conductor-message--pending" : ""),
1059
+ "data-role": m.role,
1060
+ "data-message-id": m.id,
1061
+ children: /* @__PURE__ */ jsx4(
1062
+ MessageBubble,
1063
+ {
1064
+ message: m,
1065
+ renderMessageContent,
1066
+ onRequestMenu: openMenu,
1067
+ showAppOriginChip
1068
+ }
1069
+ )
1070
+ },
1071
+ m.id
1072
+ );
1073
+ })
1074
+ ]
1075
+ }
1076
+ ),
1077
+ /* @__PURE__ */ jsx4(
1078
+ QuestionNav,
1079
+ {
1080
+ count: userQuestionCount,
1081
+ activeIndex: activeQuestion,
1082
+ visible: showQuestionNav && userQuestionCount > 1,
1083
+ onJump: handleJumpToQuestion,
1084
+ label: labels.jumpToQuestion
1085
+ }
1086
+ ),
1087
+ showScrollToBottom && /* @__PURE__ */ jsx4(
387
1088
  "button",
388
1089
  {
389
1090
  type: "button",
390
- onClick: () => {
391
- void loadEarlier();
392
- },
393
- disabled: state.loadingHistory,
394
- children: state.loadingHistory ? "\u2026" : labels.loadEarlier
395
- }
396
- ) }),
397
- state.messages.length === 0 && !state.loadingHistory && /* @__PURE__ */ jsx2("div", { className: "conductor-empty" }),
398
- state.messages.map((m) => {
399
- const isUser = m.role === "user" || m.role === "sdk";
400
- const isPending = m.id.startsWith("pending:");
401
- const content = renderMessageContent ? renderMessageContent(m) : m.content;
402
- return /* @__PURE__ */ jsx2(
403
- "div",
404
- {
405
- className: "conductor-message " + (isUser ? "conductor-message--user" : "conductor-message--assistant") + (isPending ? " conductor-message--pending" : ""),
406
- "data-role": m.role,
407
- "data-message-id": m.id,
408
- children: /* @__PURE__ */ jsx2("div", { className: "conductor-bubble", children: content })
409
- },
410
- m.id
411
- );
412
- })
1091
+ className: "conductor-scroll-to-bottom",
1092
+ "aria-label": labels.scrollToBottom,
1093
+ "data-testid": "conductor-scroll-to-bottom",
1094
+ onClick: scrollToBottom,
1095
+ children: /* @__PURE__ */ jsxs3(
1096
+ "svg",
1097
+ {
1098
+ viewBox: "0 0 24 24",
1099
+ fill: "none",
1100
+ stroke: "currentColor",
1101
+ strokeWidth: 2,
1102
+ strokeLinecap: "round",
1103
+ strokeLinejoin: "round",
1104
+ "aria-hidden": "true",
1105
+ children: [
1106
+ /* @__PURE__ */ jsx4("path", { d: "M12 5v14" }),
1107
+ /* @__PURE__ */ jsx4("path", { d: "m19 12-7 7-7-7" })
1108
+ ]
1109
+ }
1110
+ )
1111
+ }
1112
+ ),
1113
+ menuMessage && /* @__PURE__ */ jsxs3(
1114
+ "div",
1115
+ {
1116
+ className: "conductor-bubble-menu-overlay",
1117
+ onClick: closeMenu,
1118
+ "data-testid": "conductor-bubble-menu",
1119
+ children: [
1120
+ /* @__PURE__ */ jsx4("div", { className: "conductor-bubble-menu-backdrop" }),
1121
+ /* @__PURE__ */ jsxs3(
1122
+ "div",
1123
+ {
1124
+ className: "conductor-bubble-menu-sheet",
1125
+ role: "menu",
1126
+ onClick: (event) => event.stopPropagation(),
1127
+ children: [
1128
+ /* @__PURE__ */ jsx4("div", { className: "conductor-bubble-menu-handle", "aria-hidden": "true" }),
1129
+ /* @__PURE__ */ jsxs3("div", { className: "conductor-bubble-menu-actions", children: [
1130
+ /* @__PURE__ */ jsx4(
1131
+ "button",
1132
+ {
1133
+ type: "button",
1134
+ className: "conductor-bubble-menu-item",
1135
+ "data-testid": "conductor-bubble-menu-copy",
1136
+ onClick: () => {
1137
+ void handleCopy();
1138
+ },
1139
+ children: copied ? labels.copied : labels.copy
1140
+ }
1141
+ ),
1142
+ menuIsUserSide && !readOnly && /* @__PURE__ */ jsx4(
1143
+ "button",
1144
+ {
1145
+ type: "button",
1146
+ className: "conductor-bubble-menu-item",
1147
+ "data-testid": "conductor-bubble-menu-resend",
1148
+ disabled: !menuMessage.content.trim(),
1149
+ onClick: handleResend,
1150
+ children: labels.resend
1151
+ }
1152
+ ),
1153
+ canInterrupt && !readOnly && /* @__PURE__ */ jsx4(
1154
+ "button",
1155
+ {
1156
+ type: "button",
1157
+ className: "conductor-bubble-menu-item",
1158
+ "data-testid": "conductor-bubble-menu-interrupt",
1159
+ onClick: handleInterrupt,
1160
+ children: labels.interrupt
1161
+ }
1162
+ ),
1163
+ restartSupported && !readOnly && /* @__PURE__ */ jsx4(
1164
+ "button",
1165
+ {
1166
+ type: "button",
1167
+ className: "conductor-bubble-menu-item",
1168
+ "data-testid": "conductor-bubble-menu-restart",
1169
+ disabled: restartPending,
1170
+ onClick: () => {
1171
+ void handleRestart();
1172
+ },
1173
+ children: restartPending ? labels.restartPending : labels.restart
1174
+ }
1175
+ )
1176
+ ] })
1177
+ ]
1178
+ }
1179
+ )
1180
+ ]
1181
+ }
1182
+ )
413
1183
  ] });
414
1184
  }
415
1185
 
416
1186
  // src/react/components/MessageInput.tsx
417
- import { useCallback, useLayoutEffect as useLayoutEffect2, useRef as useRef3, useState } from "react";
418
- import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
419
- function MessageInput({ labels, disabled }) {
420
- const { state, send, interrupt } = useChat();
421
- const [value, setValue] = useState("");
422
- const taRef = useRef3(null);
423
- const isComposingRef = useRef3(false);
1187
+ import {
1188
+ useCallback as useCallback2,
1189
+ useEffect as useEffect5,
1190
+ useLayoutEffect as useLayoutEffect2,
1191
+ useMemo as useMemo3,
1192
+ useRef as useRef5,
1193
+ useState as useState3
1194
+ } from "react";
1195
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
1196
+ var DRAFT_STORAGE_PREFIX = "conductor-sdk-task-draft:";
1197
+ var MAX_HISTORY_ITEMS = 200;
1198
+ var INPUT_SCROLL_THRESHOLD_RATIO = 0.75;
1199
+ var INTERRUPT_PENDING_TIMEOUT_MS = 5e3;
1200
+ var getDraftStorageKey = (taskId) => `${DRAFT_STORAGE_PREFIX}${taskId}`;
1201
+ var deriveSentHistory = (messages) => {
1202
+ const seen = /* @__PURE__ */ new Set();
1203
+ const reversed = [];
1204
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
1205
+ const message = messages[i];
1206
+ if (!message || message.role !== "user") continue;
1207
+ const trimmed = (message.content ?? "").trim();
1208
+ if (!trimmed || seen.has(trimmed)) continue;
1209
+ seen.add(trimmed);
1210
+ reversed.push(trimmed);
1211
+ if (reversed.length >= MAX_HISTORY_ITEMS) break;
1212
+ }
1213
+ reversed.reverse();
1214
+ return reversed;
1215
+ };
1216
+ function MessageInput({ labels, disabled, autoFocus = false }) {
1217
+ const { state, send, interrupt, taskId } = useChat();
1218
+ const [value, setValue] = useState3("");
1219
+ const [interruptPending, setInterruptPending] = useState3(false);
1220
+ const taRef = useRef5(null);
1221
+ const isComposingRef = useRef5(false);
1222
+ const skipDraftSaveRef = useRef5(true);
1223
+ const historyCursorRef = useRef5(null);
1224
+ const historyDraftRef = useRef5("");
1225
+ const interruptTimeoutRef = useRef5(null);
1226
+ const sentHistory = useMemo3(() => deriveSentHistory(state.messages), [state.messages]);
1227
+ const replyInProgress = state.runtime?.replyInProgress === true;
1228
+ const canInterrupt = replyInProgress && Boolean(state.latestReplyId);
1229
+ useEffect5(() => {
1230
+ if (typeof window === "undefined") return;
1231
+ skipDraftSaveRef.current = true;
1232
+ historyCursorRef.current = null;
1233
+ historyDraftRef.current = "";
1234
+ try {
1235
+ setValue(window.sessionStorage.getItem(getDraftStorageKey(taskId)) ?? "");
1236
+ } catch {
1237
+ setValue("");
1238
+ }
1239
+ }, [taskId]);
1240
+ useEffect5(() => {
1241
+ if (typeof window === "undefined") return;
1242
+ if (skipDraftSaveRef.current) {
1243
+ skipDraftSaveRef.current = false;
1244
+ return;
1245
+ }
1246
+ try {
1247
+ const key = getDraftStorageKey(taskId);
1248
+ if (!value) window.sessionStorage.removeItem(key);
1249
+ else window.sessionStorage.setItem(key, value);
1250
+ } catch {
1251
+ }
1252
+ }, [value, taskId]);
424
1253
  useLayoutEffect2(() => {
425
1254
  const el = taRef.current;
426
1255
  if (!el) return;
427
1256
  el.style.height = "auto";
428
- el.style.height = `${Math.min(el.scrollHeight, 5 * 24 + 16)}px`;
1257
+ const computed = window.getComputedStyle(el);
1258
+ const lineHeight = Number.parseFloat(computed.lineHeight) || 20;
1259
+ const maxHeight = Math.max(
1260
+ lineHeight * 2,
1261
+ Math.floor(window.innerHeight * INPUT_SCROLL_THRESHOLD_RATIO)
1262
+ );
1263
+ const shouldScroll = el.scrollHeight > maxHeight;
1264
+ el.style.height = `${shouldScroll ? maxHeight : el.scrollHeight}px`;
1265
+ el.style.overflowY = shouldScroll ? "auto" : "hidden";
429
1266
  }, [value]);
430
- const handleSend = useCallback(() => {
1267
+ useEffect5(() => {
1268
+ if (!autoFocus || disabled) return;
1269
+ const el = taRef.current;
1270
+ if (!el) return;
1271
+ el.focus({ preventScroll: true });
1272
+ const end = el.value.length;
1273
+ el.setSelectionRange(end, end);
1274
+ }, [autoFocus, disabled, taskId]);
1275
+ useEffect5(() => {
1276
+ if (!replyInProgress && interruptPending) {
1277
+ setInterruptPending(false);
1278
+ if (interruptTimeoutRef.current !== null) {
1279
+ window.clearTimeout(interruptTimeoutRef.current);
1280
+ interruptTimeoutRef.current = null;
1281
+ }
1282
+ }
1283
+ }, [replyInProgress, interruptPending]);
1284
+ useEffect5(
1285
+ () => () => {
1286
+ if (interruptTimeoutRef.current !== null) {
1287
+ window.clearTimeout(interruptTimeoutRef.current);
1288
+ }
1289
+ },
1290
+ []
1291
+ );
1292
+ const moveCaretToEnd = useCallback2((next) => {
1293
+ const el = taRef.current;
1294
+ if (!el) return;
1295
+ const pos = (next ?? el.value).length;
1296
+ requestAnimationFrame(() => {
1297
+ el.focus({ preventScroll: true });
1298
+ el.setSelectionRange(pos, pos);
1299
+ });
1300
+ }, []);
1301
+ const handleSend = useCallback2(() => {
431
1302
  if (!value.trim() || disabled) return;
432
1303
  void send(value);
1304
+ historyCursorRef.current = null;
1305
+ historyDraftRef.current = "";
433
1306
  setValue("");
434
1307
  }, [send, value, disabled]);
435
- const handleKeyDown = useCallback(
1308
+ const triggerInterrupt = useCallback2(() => {
1309
+ if (!canInterrupt || interruptPending || disabled) return;
1310
+ setInterruptPending(true);
1311
+ void interrupt();
1312
+ if (interruptTimeoutRef.current !== null) window.clearTimeout(interruptTimeoutRef.current);
1313
+ interruptTimeoutRef.current = window.setTimeout(() => {
1314
+ interruptTimeoutRef.current = null;
1315
+ setInterruptPending(false);
1316
+ }, INTERRUPT_PENDING_TIMEOUT_MS);
1317
+ }, [canInterrupt, interruptPending, interrupt, disabled]);
1318
+ const handleKeyDown = useCallback2(
436
1319
  (e) => {
437
1320
  const nativeComposing = e.nativeEvent.isComposing === true;
438
- if (isComposingRef.current || nativeComposing) return;
439
- if (e.key === "Enter" && !e.shiftKey) {
1321
+ const composing = isComposingRef.current || nativeComposing;
1322
+ if (e.key === "ArrowUp") {
1323
+ if (composing || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
1324
+ if (sentHistory.length === 0) return;
1325
+ e.preventDefault();
1326
+ let next;
1327
+ if (historyCursorRef.current === null) {
1328
+ historyDraftRef.current = value;
1329
+ next = sentHistory[sentHistory.length - 1];
1330
+ } else {
1331
+ const idx = sentHistory.lastIndexOf(historyCursorRef.current);
1332
+ if (idx === -1) next = sentHistory[sentHistory.length - 1];
1333
+ else if (idx > 0) next = sentHistory[idx - 1];
1334
+ else next = sentHistory[0];
1335
+ }
1336
+ historyCursorRef.current = next;
1337
+ setValue(next);
1338
+ moveCaretToEnd(next);
1339
+ return;
1340
+ }
1341
+ if (e.key === "ArrowDown") {
1342
+ if (composing || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
1343
+ if (historyCursorRef.current === null) return;
1344
+ e.preventDefault();
1345
+ const idx = sentHistory.lastIndexOf(historyCursorRef.current);
1346
+ if (idx === -1 || idx >= sentHistory.length - 1) {
1347
+ historyCursorRef.current = null;
1348
+ setValue(historyDraftRef.current);
1349
+ moveCaretToEnd(historyDraftRef.current);
1350
+ return;
1351
+ }
1352
+ const next = sentHistory[idx + 1];
1353
+ historyCursorRef.current = next;
1354
+ setValue(next);
1355
+ moveCaretToEnd(next);
1356
+ return;
1357
+ }
1358
+ if (e.key === "Escape") {
1359
+ if (composing || value.trim() || !canInterrupt || interruptPending) return;
440
1360
  e.preventDefault();
441
- handleSend();
1361
+ triggerInterrupt();
1362
+ return;
442
1363
  }
1364
+ if (e.key !== "Enter" || composing) return;
1365
+ if (e.shiftKey) return;
1366
+ if (e.ctrlKey || e.metaKey) {
1367
+ e.preventDefault();
1368
+ const el = taRef.current;
1369
+ if (!el) return;
1370
+ const start = el.selectionStart;
1371
+ const end = el.selectionEnd;
1372
+ const next = value.slice(0, start) + "\n" + value.slice(end);
1373
+ setValue(next);
1374
+ window.setTimeout(() => {
1375
+ el.selectionStart = el.selectionEnd = start + 1;
1376
+ }, 0);
1377
+ return;
1378
+ }
1379
+ e.preventDefault();
1380
+ handleSend();
443
1381
  },
444
- [handleSend]
1382
+ [
1383
+ sentHistory,
1384
+ value,
1385
+ canInterrupt,
1386
+ interruptPending,
1387
+ triggerInterrupt,
1388
+ handleSend,
1389
+ moveCaretToEnd
1390
+ ]
445
1391
  );
446
- const handleCompositionStart = useCallback(() => {
447
- isComposingRef.current = true;
448
- }, []);
449
- const handleCompositionEnd = useCallback(() => {
450
- isComposingRef.current = false;
451
- }, []);
452
- const replyInProgress = state.runtime?.replyInProgress === true;
453
- const canInterrupt = replyInProgress && Boolean(state.latestReplyId);
454
- return /* @__PURE__ */ jsxs2("div", { className: "conductor-message-input", children: [
455
- /* @__PURE__ */ jsx3(
1392
+ const canSend = Boolean(value.trim()) && !disabled;
1393
+ return /* @__PURE__ */ jsxs4("div", { className: "conductor-message-input", children: [
1394
+ /* @__PURE__ */ jsx5(
456
1395
  "textarea",
457
1396
  {
458
1397
  ref: taRef,
459
1398
  value,
460
1399
  onChange: (e) => setValue(e.target.value),
461
1400
  onKeyDown: handleKeyDown,
462
- onCompositionStart: handleCompositionStart,
463
- onCompositionEnd: handleCompositionEnd,
1401
+ onCompositionStart: () => {
1402
+ isComposingRef.current = true;
1403
+ },
1404
+ onCompositionEnd: () => {
1405
+ isComposingRef.current = false;
1406
+ },
464
1407
  placeholder: labels.inputPlaceholder,
465
1408
  rows: 1,
466
1409
  className: "conductor-message-input__textarea",
467
1410
  disabled
468
1411
  }
469
1412
  ),
470
- /* @__PURE__ */ jsx3("div", { className: "conductor-message-input__actions", children: canInterrupt ? /* @__PURE__ */ jsx3(
1413
+ /* @__PURE__ */ jsx5("div", { className: "conductor-message-input__actions", children: canInterrupt && !disabled ? /* @__PURE__ */ jsx5(
471
1414
  "button",
472
1415
  {
473
1416
  type: "button",
474
1417
  className: "conductor-button conductor-button--interrupt",
475
- onClick: () => void interrupt(),
476
- children: labels.interrupt
1418
+ onClick: triggerInterrupt,
1419
+ disabled: interruptPending,
1420
+ children: interruptPending ? "\u2026" : labels.interrupt
477
1421
  }
478
- ) : /* @__PURE__ */ jsx3(
1422
+ ) : /* @__PURE__ */ jsx5(
479
1423
  "button",
480
1424
  {
481
1425
  type: "button",
482
1426
  className: "conductor-button conductor-button--send",
483
1427
  onClick: handleSend,
484
- disabled: !value.trim() || disabled,
1428
+ disabled: !canSend,
485
1429
  children: labels.send
486
1430
  }
487
1431
  ) })
@@ -489,28 +1433,34 @@ function MessageInput({ labels, disabled }) {
489
1433
  }
490
1434
 
491
1435
  // src/react/components/RuntimeStatusBar.tsx
492
- import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
1436
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
493
1437
  function RuntimeStatusBar({ labels }) {
494
1438
  const { state } = useChat();
495
1439
  const runtime = state.runtime;
496
1440
  const isThinking = runtime?.replyInProgress === true || runtime?.state === "thinking";
497
- const text = pickStatusText(runtime?.state, runtime?.statusLine, labels);
1441
+ const text = pickStatusText(
1442
+ runtime?.state,
1443
+ runtime?.statusLine,
1444
+ runtime?.statusDoneLine,
1445
+ labels
1446
+ );
498
1447
  const showConnection = state.connectionState !== "connected" && state.connectionState !== "offline";
499
- return /* @__PURE__ */ jsxs3("div", { className: "conductor-runtime-status", role: "status", "aria-live": "polite", children: [
500
- /* @__PURE__ */ jsx4(
1448
+ return /* @__PURE__ */ jsxs5("div", { className: "conductor-runtime-status", role: "status", "aria-live": "polite", children: [
1449
+ /* @__PURE__ */ jsx6(
501
1450
  "span",
502
1451
  {
503
1452
  className: "conductor-runtime-indicator" + (isThinking ? " conductor-runtime-indicator--active" : ""),
504
1453
  "aria-hidden": "true"
505
1454
  }
506
1455
  ),
507
- /* @__PURE__ */ jsx4("span", { className: "conductor-runtime-text", children: text }),
508
- state.error && /* @__PURE__ */ jsx4("span", { className: "conductor-runtime-error", role: "alert", children: state.error.message }),
509
- showConnection && /* @__PURE__ */ jsx4("span", { className: "conductor-runtime-connection", children: state.connectionState === "reconnecting" ? "\u2026" : "" })
1456
+ /* @__PURE__ */ jsx6("span", { className: "conductor-runtime-text", children: text }),
1457
+ state.error && /* @__PURE__ */ jsx6("span", { className: "conductor-runtime-error", role: "alert", children: state.error.message }),
1458
+ showConnection && /* @__PURE__ */ jsx6("span", { className: "conductor-runtime-connection", children: state.connectionState === "reconnecting" ? "\u2026" : "" })
510
1459
  ] });
511
1460
  }
512
- function pickStatusText(state, statusLine, labels) {
513
- if (statusLine) return statusLine;
1461
+ function pickStatusText(state, statusLine, statusDoneLine, labels) {
1462
+ if (statusLine && statusLine.trim()) return statusLine;
1463
+ if (statusDoneLine && statusDoneLine.trim()) return statusDoneLine;
514
1464
  switch (state) {
515
1465
  case "thinking":
516
1466
  return labels.statusThinking;
@@ -526,7 +1476,7 @@ function pickStatusText(state, statusLine, labels) {
526
1476
  }
527
1477
 
528
1478
  // src/react/ChatView.tsx
529
- import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
1479
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
530
1480
  var DEFAULT_LABELS = {
531
1481
  statusThinking: "Thinking\u2026",
532
1482
  statusToolCall: "Calling tool\u2026",
@@ -536,29 +1486,39 @@ var DEFAULT_LABELS = {
536
1486
  send: "Send",
537
1487
  interrupt: "Stop",
538
1488
  restart: "Restart",
539
- loadEarlier: "Load earlier messages"
1489
+ loadEarlier: "Load earlier messages",
1490
+ copy: "Copy",
1491
+ copied: "Copied",
1492
+ resend: "Resend",
1493
+ scrollToBottom: "Scroll to latest message",
1494
+ jumpToQuestion: "Jump to question",
1495
+ emptyTitle: "No messages yet",
1496
+ emptyBody: "Send a message to get started \u2014 the conversation will appear here.",
1497
+ restartPending: "Restarting\u2026"
540
1498
  };
541
1499
  function ChatView(props) {
542
1500
  const labels = { ...DEFAULT_LABELS, ...props.labels ?? {} };
543
1501
  const themeStyle = props.theme ? toCssVariableStyle(props.theme) : void 0;
544
1502
  const className = ["conductor-chat-view", props.className].filter(Boolean).join(" ");
545
- return /* @__PURE__ */ jsx5(
1503
+ return /* @__PURE__ */ jsx7(
546
1504
  "div",
547
1505
  {
548
1506
  className,
549
1507
  "data-task-id": props.taskId,
550
1508
  "data-layout": props.layout ?? "auto",
551
1509
  style: themeStyle,
552
- children: /* @__PURE__ */ jsxs4(ChatProvider, { taskId: props.taskId, adapter: props.adapter, onError: props.onError, children: [
553
- /* @__PURE__ */ jsx5(RuntimeStatusBar, { labels }),
554
- /* @__PURE__ */ jsx5(
1510
+ children: /* @__PURE__ */ jsxs6(ChatProvider, { taskId: props.taskId, adapter: props.adapter, onError: props.onError, children: [
1511
+ /* @__PURE__ */ jsx7(RuntimeStatusBar, { labels }),
1512
+ /* @__PURE__ */ jsx7(
555
1513
  MessageList,
556
1514
  {
557
1515
  labels,
558
- renderMessageContent: props.renderMessageContent
1516
+ renderMessageContent: props.renderMessageContent,
1517
+ showAppOriginChip: props.showAppOriginChip,
1518
+ readOnly: props.readOnly
559
1519
  }
560
1520
  ),
561
- /* @__PURE__ */ jsx5(MessageInput, { labels, disabled: props.readOnly })
1521
+ /* @__PURE__ */ jsx7(MessageInput, { labels, disabled: props.readOnly, autoFocus: props.autoFocus })
562
1522
  ] })
563
1523
  }
564
1524
  );
@@ -711,7 +1671,7 @@ function createRestAdapter(options) {
711
1671
  if (!text) return void 0;
712
1672
  return JSON.parse(text);
713
1673
  }
714
- return {
1674
+ const adapter = {
715
1675
  async fetchHistory(taskId, opts) {
716
1676
  if (!taskId) {
717
1677
  throw new ConductorAppError({
@@ -824,6 +1784,23 @@ function createRestAdapter(options) {
824
1784
  );
825
1785
  }
826
1786
  };
1787
+ if (options.enableRestart) {
1788
+ adapter.restart = async (taskId, opts) => {
1789
+ if (!taskId) {
1790
+ throw new ConductorAppError({
1791
+ code: "invalid_input",
1792
+ message: "restart requires a taskId"
1793
+ });
1794
+ }
1795
+ const restartMode = opts?.restartMode?.trim();
1796
+ await jsonFetch(
1797
+ "POST",
1798
+ `/tasks/${encodeURIComponent(taskId)}/restart`,
1799
+ { body: restartMode ? { restart_mode: restartMode } : {} }
1800
+ );
1801
+ };
1802
+ }
1803
+ return adapter;
827
1804
  }
828
1805
  function buildUrl(base, path, query) {
829
1806
  const cleanPath = path.startsWith("/") ? path : `/${path}`;