@xinghunm/ai-chat 1.1.0 → 1.1.2

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.
package/dist/index.d.mts CHANGED
@@ -394,6 +394,7 @@ interface AiChatLabels {
394
394
  sendButton?: string;
395
395
  stopButton?: string;
396
396
  retryButton?: string;
397
+ scrollToLatest?: string;
397
398
  placeholder?: string;
398
399
  modeLabelAsk?: string;
399
400
  modeLabelPlan?: string;
package/dist/index.d.ts CHANGED
@@ -394,6 +394,7 @@ interface AiChatLabels {
394
394
  sendButton?: string;
395
395
  stopButton?: string;
396
396
  retryButton?: string;
397
+ scrollToLatest?: string;
397
398
  placeholder?: string;
398
399
  modeLabelAsk?: string;
399
400
  modeLabelPlan?: string;
package/dist/index.js CHANGED
@@ -68,6 +68,7 @@ var DEFAULT_AI_CHAT_LABELS = {
68
68
  sendButton: "Send",
69
69
  stopButton: "Stop",
70
70
  retryButton: "Retry",
71
+ scrollToLatest: "Jump to latest",
71
72
  placeholder: "Ask something...",
72
73
  modeLabelAsk: "Ask",
73
74
  modeLabelPlan: "Plan",
@@ -2338,11 +2339,11 @@ var TextInput = import_styled4.default.input`
2338
2339
  color: rgba(255, 255, 255, 0.34);
2339
2340
  }
2340
2341
  `;
2341
- var InlineOtherInput = (0, import_styled4.default)(import_compass_ui.InputField)`
2342
+ var InlineOtherInput = (0, import_styled4.default)(import_compass_ui.Input)`
2342
2343
  width: 100%;
2343
2344
  margin-top: 0;
2344
2345
 
2345
- .compass-input-field-wrapper {
2346
+ .compass-input-wrapper {
2346
2347
  min-height: 30px;
2347
2348
  border: 1px solid rgba(255, 255, 255, 0.1);
2348
2349
  border-radius: 10px;
@@ -2351,22 +2352,22 @@ var InlineOtherInput = (0, import_styled4.default)(import_compass_ui.InputField)
2351
2352
  padding: 2px 9px;
2352
2353
  }
2353
2354
 
2354
- .compass-input-field-wrapper:hover {
2355
+ .compass-input-wrapper:hover {
2355
2356
  border-color: rgba(126, 160, 255, 0.28);
2356
2357
  }
2357
2358
 
2358
- .compass-input-field-wrapper:focus-within {
2359
+ .compass-input-wrapper:focus-within {
2359
2360
  border-color: rgba(126, 160, 255, 0.42);
2360
2361
  box-shadow: 0 0 0 1px rgba(126, 160, 255, 0.14);
2361
2362
  }
2362
2363
 
2363
- .compass-input-field-input {
2364
+ .compass-input-input {
2364
2365
  color: rgba(255, 255, 255, 0.92);
2365
2366
  font-size: 13px;
2366
2367
  line-height: 1.2;
2367
2368
  }
2368
2369
 
2369
- .compass-input-field-input::placeholder {
2370
+ .compass-input-input::placeholder {
2370
2371
  color: rgba(255, 255, 255, 0.34);
2371
2372
  }
2372
2373
  `;
@@ -3360,6 +3361,8 @@ var HeroSubtitle = import_styled8.default.p`
3360
3361
 
3361
3362
  // src/components/chat-thread/index.tsx
3362
3363
  var import_jsx_runtime10 = require("@emotion/react/jsx-runtime");
3364
+ var CHAT_THREAD_PINNED_THRESHOLD_PX = 32;
3365
+ var isThreadPinnedToBottom = (container) => container.scrollHeight - container.clientHeight - container.scrollTop <= CHAT_THREAD_PINNED_THRESHOLD_PX;
3363
3366
  var renderChatMessage = ({
3364
3367
  message,
3365
3368
  mode,
@@ -3433,6 +3436,7 @@ var ChatThreadView = ({
3433
3436
  streamingMessage,
3434
3437
  error,
3435
3438
  retryButtonLabel,
3439
+ scrollToLatestLabel,
3436
3440
  onRetry,
3437
3441
  onConfirmationSubmit,
3438
3442
  onQuestionnaireSubmit,
@@ -3446,9 +3450,15 @@ var ChatThreadView = ({
3446
3450
  const latestTurn = conversationTurns[conversationTurns.length - 1];
3447
3451
  const previousTurns = conversationTurns.slice(0, -1);
3448
3452
  const latestUserMessageId = latestTurn?.userMessage?.id;
3449
- const latestUserMessageRef = (0, import_react11.useRef)(null);
3453
+ const latestHistoryMessage = historyMessages[historyMessages.length - 1];
3454
+ const latestTurnRef = (0, import_react11.useRef)(null);
3450
3455
  const reservedSpaceFrameRef = (0, import_react11.useRef)(null);
3456
+ const isPinnedRef = (0, import_react11.useRef)(true);
3457
+ const lastHistoryMessageIdRef = (0, import_react11.useRef)(latestHistoryMessage?.id);
3458
+ const lastStreamingMessageIdRef = (0, import_react11.useRef)(streamingMessage?.id);
3451
3459
  const [latestTurnMinHeight, setLatestTurnMinHeight] = (0, import_react11.useState)(0);
3460
+ const [isDetached, setIsDetached] = (0, import_react11.useState)(false);
3461
+ const [pendingNewMessageCount, setPendingNewMessageCount] = (0, import_react11.useState)(0);
3452
3462
  const measureLatestTurnMinHeight = (0, import_react11.useCallback)(() => {
3453
3463
  const container = containerRef.current;
3454
3464
  if (!container)
@@ -3459,26 +3469,86 @@ var ChatThreadView = ({
3459
3469
  const nextMinHeight = Math.max(0, container.clientHeight - paddingTop - paddingBottom);
3460
3470
  setLatestTurnMinHeight((current) => current === nextMinHeight ? current : nextMinHeight);
3461
3471
  }, []);
3462
- const scrollLatestUserMessageToTop = (0, import_react11.useCallback)(() => {
3472
+ const scrollToBottom = (0, import_react11.useCallback)((force = false) => {
3463
3473
  const container = containerRef.current;
3464
- const target = latestUserMessageRef.current;
3465
- if (!container || !target)
3466
- return;
3467
- const containerRect = container.getBoundingClientRect();
3468
- const targetRect = target.getBoundingClientRect();
3469
- const nextScrollTop = Math.max(
3470
- 0,
3471
- container.scrollTop + (targetRect.top - containerRect.top) - CHAT_THREAD_SCROLL_TOP_GAP
3472
- );
3474
+ if (!container)
3475
+ return false;
3476
+ if (!force && !isPinnedRef.current)
3477
+ return false;
3478
+ const nextScrollTop = Math.max(0, container.scrollHeight - container.clientHeight);
3479
+ container.scrollTop = nextScrollTop;
3473
3480
  if (typeof container.scrollTo === "function") {
3474
3481
  container.scrollTo({
3475
3482
  top: nextScrollTop,
3476
3483
  behavior: "auto"
3477
3484
  });
3485
+ }
3486
+ return true;
3487
+ }, []);
3488
+ const markThreadPinned = (0, import_react11.useCallback)(() => {
3489
+ isPinnedRef.current = true;
3490
+ setIsDetached(false);
3491
+ setPendingNewMessageCount(0);
3492
+ }, []);
3493
+ const scrollToBottomAndPin = (0, import_react11.useCallback)(
3494
+ (force = false) => {
3495
+ const didScroll = scrollToBottom(force);
3496
+ if (!didScroll)
3497
+ return;
3498
+ markThreadPinned();
3499
+ },
3500
+ [markThreadPinned, scrollToBottom]
3501
+ );
3502
+ const handleContainerScroll = (0, import_react11.useCallback)(() => {
3503
+ const container = containerRef.current;
3504
+ if (!container)
3478
3505
  return;
3506
+ const nextPinned = isThreadPinnedToBottom(container);
3507
+ isPinnedRef.current = nextPinned;
3508
+ setIsDetached(!nextPinned);
3509
+ if (nextPinned) {
3510
+ setPendingNewMessageCount(0);
3479
3511
  }
3480
- container.scrollTop = nextScrollTop;
3481
3512
  }, []);
3513
+ (0, import_react11.useLayoutEffect)(() => {
3514
+ const nextHistoryMessageId = latestHistoryMessage?.id;
3515
+ if (lastHistoryMessageIdRef.current === nextHistoryMessageId) {
3516
+ return;
3517
+ }
3518
+ lastHistoryMessageIdRef.current = nextHistoryMessageId;
3519
+ if (!latestHistoryMessage) {
3520
+ return;
3521
+ }
3522
+ if (latestHistoryMessage.role === "user") {
3523
+ window.requestAnimationFrame(() => {
3524
+ if (!scrollToBottom(true)) {
3525
+ return;
3526
+ }
3527
+ markThreadPinned();
3528
+ });
3529
+ return;
3530
+ }
3531
+ if (!isPinnedRef.current && latestHistoryMessage.role === "assistant" && latestHistoryMessage.id !== lastStreamingMessageIdRef.current) {
3532
+ window.requestAnimationFrame(() => {
3533
+ setPendingNewMessageCount((current) => current + 1);
3534
+ });
3535
+ }
3536
+ }, [latestHistoryMessage, markThreadPinned, scrollToBottom]);
3537
+ (0, import_react11.useLayoutEffect)(() => {
3538
+ const nextStreamingMessageId = streamingMessage?.id;
3539
+ if (lastStreamingMessageIdRef.current === nextStreamingMessageId) {
3540
+ return;
3541
+ }
3542
+ lastStreamingMessageIdRef.current = nextStreamingMessageId;
3543
+ if (!streamingMessage || streamingMessage.role !== "assistant") {
3544
+ return;
3545
+ }
3546
+ if (!isPinnedRef.current) {
3547
+ window.requestAnimationFrame(() => {
3548
+ setPendingNewMessageCount((current) => current + 1);
3549
+ });
3550
+ }
3551
+ }, [streamingMessage]);
3482
3552
  (0, import_react11.useLayoutEffect)(() => {
3483
3553
  if (reservedSpaceFrameRef.current !== null) {
3484
3554
  window.cancelAnimationFrame(reservedSpaceFrameRef.current);
@@ -3488,6 +3558,7 @@ var ChatThreadView = ({
3488
3558
  reservedSpaceFrameRef.current = window.requestAnimationFrame(() => {
3489
3559
  reservedSpaceFrameRef.current = null;
3490
3560
  setLatestTurnMinHeight((current) => current === 0 ? current : 0);
3561
+ scrollToBottom();
3491
3562
  });
3492
3563
  return () => {
3493
3564
  if (reservedSpaceFrameRef.current !== null) {
@@ -3499,7 +3570,7 @@ var ChatThreadView = ({
3499
3570
  reservedSpaceFrameRef.current = window.requestAnimationFrame(() => {
3500
3571
  reservedSpaceFrameRef.current = null;
3501
3572
  measureLatestTurnMinHeight();
3502
- scrollLatestUserMessageToTop();
3573
+ scrollToBottom();
3503
3574
  });
3504
3575
  return () => {
3505
3576
  if (reservedSpaceFrameRef.current !== null) {
@@ -3507,13 +3578,18 @@ var ChatThreadView = ({
3507
3578
  reservedSpaceFrameRef.current = null;
3508
3579
  }
3509
3580
  };
3510
- }, [latestUserMessageId, measureLatestTurnMinHeight, scrollLatestUserMessageToTop]);
3581
+ }, [latestTurn, latestUserMessageId, error, measureLatestTurnMinHeight, scrollToBottom]);
3511
3582
  (0, import_react11.useLayoutEffect)(() => {
3512
- if (!latestUserMessageId)
3583
+ if (!latestTurn)
3513
3584
  return;
3514
3585
  const handleResize = () => {
3586
+ if (!latestUserMessageId) {
3587
+ setLatestTurnMinHeight((current) => current === 0 ? current : 0);
3588
+ scrollToBottom();
3589
+ return;
3590
+ }
3515
3591
  measureLatestTurnMinHeight();
3516
- scrollLatestUserMessageToTop();
3592
+ scrollToBottom();
3517
3593
  };
3518
3594
  const container = containerRef.current;
3519
3595
  let resizeObserver = null;
@@ -3528,57 +3604,101 @@ var ChatThreadView = ({
3528
3604
  resizeObserver?.disconnect();
3529
3605
  window.removeEventListener("resize", handleResize);
3530
3606
  };
3531
- }, [latestUserMessageId, measureLatestTurnMinHeight, scrollLatestUserMessageToTop]);
3532
- return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Container, { ref: containerRef, "data-testid": "chat-thread", children: [
3533
- previousTurns.map((turn) => /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(ConversationTurn, { "data-testid": "chat-thread-turn", children: [
3534
- turn.userMessage ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(MessageSlot, { children: renderChatMessage({
3535
- message: turn.userMessage,
3536
- mode: activeSessionMode,
3537
- onConfirmationSubmit,
3538
- onQuestionnaireSubmit,
3539
- renderMessageBlock
3540
- }) }) : null,
3541
- turn.responseMessages.map((message) => /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(MessageSlot, { children: renderChatMessage({
3542
- message,
3543
- mode: activeSessionMode,
3544
- onConfirmationSubmit,
3545
- onQuestionnaireSubmit,
3546
- renderMessageBlock
3547
- }) }, message.id))
3548
- ] }, turn.id)),
3549
- latestTurn ? /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(
3550
- ConversationTurn,
3607
+ }, [latestTurn, latestUserMessageId, measureLatestTurnMinHeight, scrollToBottom]);
3608
+ (0, import_react11.useLayoutEffect)(() => {
3609
+ const latestTurnElement = latestTurnRef.current;
3610
+ if (!latestTurnElement || typeof ResizeObserver === "undefined") {
3611
+ return;
3612
+ }
3613
+ const observer = new ResizeObserver(() => {
3614
+ scrollToBottom();
3615
+ });
3616
+ observer.observe(latestTurnElement);
3617
+ return () => {
3618
+ observer.disconnect();
3619
+ };
3620
+ }, [latestTurn, scrollToBottom]);
3621
+ (0, import_react11.useLayoutEffect)(() => {
3622
+ const latestTurnElement = latestTurnRef.current;
3623
+ if (!latestTurnElement || typeof MutationObserver === "undefined") {
3624
+ return;
3625
+ }
3626
+ const observer = new MutationObserver(() => {
3627
+ scrollToBottom();
3628
+ });
3629
+ observer.observe(latestTurnElement, {
3630
+ childList: true,
3631
+ subtree: true,
3632
+ characterData: true
3633
+ });
3634
+ return () => {
3635
+ observer.disconnect();
3636
+ };
3637
+ }, [latestTurn, scrollToBottom]);
3638
+ return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(ThreadViewport, { children: [
3639
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Container, { ref: containerRef, "data-testid": "chat-thread", onScroll: handleContainerScroll, children: [
3640
+ previousTurns.map((turn) => /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(ConversationTurn, { "data-testid": "chat-thread-turn", children: [
3641
+ turn.userMessage ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(MessageSlot, { children: renderChatMessage({
3642
+ message: turn.userMessage,
3643
+ mode: activeSessionMode,
3644
+ onConfirmationSubmit,
3645
+ onQuestionnaireSubmit,
3646
+ renderMessageBlock
3647
+ }) }) : null,
3648
+ turn.responseMessages.map((message) => /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(MessageSlot, { children: renderChatMessage({
3649
+ message,
3650
+ mode: activeSessionMode,
3651
+ onConfirmationSubmit,
3652
+ onQuestionnaireSubmit,
3653
+ renderMessageBlock
3654
+ }) }, message.id))
3655
+ ] }, turn.id)),
3656
+ latestTurn ? /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(
3657
+ ConversationTurn,
3658
+ {
3659
+ ref: latestTurnRef,
3660
+ "data-testid": "chat-thread-latest-turn",
3661
+ style: latestTurnMinHeight > 0 ? { minHeight: `${latestTurnMinHeight}px` } : void 0,
3662
+ children: [
3663
+ latestTurn.userMessage ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
3664
+ MessageSlot,
3665
+ {
3666
+ "data-testid": "chat-latest-user-anchor",
3667
+ style: { scrollMarginTop: `${CHAT_THREAD_SCROLL_TOP_GAP}px` },
3668
+ children: renderChatMessage({
3669
+ message: latestTurn.userMessage,
3670
+ mode: activeSessionMode,
3671
+ onConfirmationSubmit,
3672
+ onQuestionnaireSubmit,
3673
+ renderMessageBlock
3674
+ })
3675
+ }
3676
+ ) : null,
3677
+ latestTurn.responseMessages.map((message) => /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(MessageSlot, { children: renderChatMessage({
3678
+ message,
3679
+ mode: activeSessionMode,
3680
+ onConfirmationSubmit,
3681
+ onQuestionnaireSubmit,
3682
+ renderMessageBlock
3683
+ }) }, message.id)),
3684
+ error ? renderErrorState({ error, onRetry, retryButtonLabel }) : null
3685
+ ]
3686
+ }
3687
+ ) : null,
3688
+ !latestTurn && error ? renderErrorState({ error, onRetry, retryButtonLabel }) : null
3689
+ ] }),
3690
+ isDetached ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(ScrollToLatestOverlay, { children: /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(
3691
+ ScrollToLatestButton,
3551
3692
  {
3552
- "data-testid": "chat-thread-latest-turn",
3553
- style: latestTurnMinHeight > 0 ? { minHeight: `${latestTurnMinHeight}px` } : void 0,
3693
+ type: "button",
3694
+ "data-testid": "chat-thread-scroll-to-latest",
3695
+ onClick: () => scrollToBottomAndPin(true),
3554
3696
  children: [
3555
- latestTurn.userMessage ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
3556
- MessageSlot,
3557
- {
3558
- ref: latestUserMessageRef,
3559
- "data-testid": "chat-latest-user-anchor",
3560
- style: { scrollMarginTop: `${CHAT_THREAD_SCROLL_TOP_GAP}px` },
3561
- children: renderChatMessage({
3562
- message: latestTurn.userMessage,
3563
- mode: activeSessionMode,
3564
- onConfirmationSubmit,
3565
- onQuestionnaireSubmit,
3566
- renderMessageBlock
3567
- })
3568
- }
3569
- ) : null,
3570
- latestTurn.responseMessages.map((message) => /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(MessageSlot, { children: renderChatMessage({
3571
- message,
3572
- mode: activeSessionMode,
3573
- onConfirmationSubmit,
3574
- onQuestionnaireSubmit,
3575
- renderMessageBlock
3576
- }) }, message.id)),
3577
- error ? renderErrorState({ error, onRetry, retryButtonLabel }) : null
3697
+ scrollToLatestLabel,
3698
+ pendingNewMessageCount > 0 ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(ScrollToLatestBadge, { "data-testid": "chat-thread-scroll-to-latest-count", children: pendingNewMessageCount }) : null
3578
3699
  ]
3579
3700
  }
3580
- ) : null,
3581
- !latestTurn && error ? renderErrorState({ error, onRetry, retryButtonLabel }) : null
3701
+ ) }) : null
3582
3702
  ] });
3583
3703
  };
3584
3704
  var EMPTY_MESSAGES = [];
@@ -3666,13 +3786,21 @@ var ChatThread = () => {
3666
3786
  streamingMessage,
3667
3787
  error,
3668
3788
  retryButtonLabel: labels.retryButton,
3789
+ scrollToLatestLabel: labels.scrollToLatest,
3669
3790
  onRetry: handleRetry,
3670
3791
  onConfirmationSubmit: handleConfirmation,
3671
3792
  onQuestionnaireSubmit: handleQuestionnaireSubmit,
3672
3793
  renderMessageBlock
3673
- }
3794
+ },
3795
+ activeSessionId ?? "chat-thread-empty"
3674
3796
  );
3675
3797
  };
3798
+ var ThreadViewport = import_styled9.default.div`
3799
+ position: relative;
3800
+ display: flex;
3801
+ flex: 1;
3802
+ min-height: 0;
3803
+ `;
3676
3804
  var Container = import_styled9.default.div`
3677
3805
  display: flex;
3678
3806
  flex: 1;
@@ -3680,8 +3808,7 @@ var Container = import_styled9.default.div`
3680
3808
  gap: 18px;
3681
3809
  min-height: 0;
3682
3810
  overflow: auto;
3683
- padding: 24px;
3684
- margin-bottom: 24px;
3811
+ padding: 24px 24px 88px;
3685
3812
  overscroll-behavior: contain;
3686
3813
 
3687
3814
  &::-webkit-scrollbar {
@@ -3733,6 +3860,48 @@ var RetryButton = import_styled9.default.button`
3733
3860
  background: rgba(255, 255, 255, 0.08);
3734
3861
  }
3735
3862
  `;
3863
+ var ScrollToLatestOverlay = import_styled9.default.div`
3864
+ position: absolute;
3865
+ right: 24px;
3866
+ bottom: 24px;
3867
+ left: 24px;
3868
+ display: flex;
3869
+ justify-content: center;
3870
+ pointer-events: none;
3871
+ `;
3872
+ var ScrollToLatestButton = import_styled9.default.button`
3873
+ display: inline-flex;
3874
+ align-items: center;
3875
+ gap: 8px;
3876
+ border: 1px solid rgba(255, 255, 255, 0.14);
3877
+ border-radius: 999px;
3878
+ background: rgba(17, 18, 21, 0.92);
3879
+ color: rgba(255, 255, 255, 0.9);
3880
+ font-size: 12px;
3881
+ line-height: 1;
3882
+ padding: 10px 14px;
3883
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
3884
+ cursor: pointer;
3885
+ z-index: 1;
3886
+ pointer-events: auto;
3887
+
3888
+ &:hover {
3889
+ background: rgba(28, 30, 36, 0.96);
3890
+ }
3891
+ `;
3892
+ var ScrollToLatestBadge = import_styled9.default.span`
3893
+ display: inline-flex;
3894
+ min-width: 18px;
3895
+ height: 18px;
3896
+ align-items: center;
3897
+ justify-content: center;
3898
+ padding: 0 6px;
3899
+ border-radius: 999px;
3900
+ background: rgba(109, 170, 255, 0.2);
3901
+ color: #9ac0ff;
3902
+ font-size: 11px;
3903
+ font-weight: 600;
3904
+ `;
3736
3905
 
3737
3906
  // src/components/chat-composer/index.tsx
3738
3907
  var import_react15 = require("react");
@@ -4940,7 +5109,7 @@ var ChatComposerView = ({
4940
5109
  }
4941
5110
  ) : null,
4942
5111
  /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
4943
- Input,
5112
+ Input2,
4944
5113
  {
4945
5114
  ref: inputRef,
4946
5115
  "data-testid": "chat-composer-input",
@@ -5084,7 +5253,7 @@ var InputArea = import_styled14.default.div`
5084
5253
  grid-area: input;
5085
5254
  position: relative;
5086
5255
  `;
5087
- var Input = import_styled14.default.textarea`
5256
+ var Input2 = import_styled14.default.textarea`
5088
5257
  --textarea-line-height: ${CHAT_COMPOSER_LINE_HEIGHT_PX}px;
5089
5258
  --textarea-min-rows: ${CHAT_COMPOSER_MIN_ROWS};
5090
5259
  --textarea-max-rows: ${CHAT_COMPOSER_MAX_ROWS};
package/dist/index.mjs CHANGED
@@ -21,6 +21,7 @@ var DEFAULT_AI_CHAT_LABELS = {
21
21
  sendButton: "Send",
22
22
  stopButton: "Stop",
23
23
  retryButton: "Retry",
24
+ scrollToLatest: "Jump to latest",
24
25
  placeholder: "Ask something...",
25
26
  modeLabelAsk: "Ask",
26
27
  modeLabelPlan: "Plan",
@@ -1494,7 +1495,7 @@ import {
1494
1495
  useState as useState2
1495
1496
  } from "react";
1496
1497
  import styled4 from "@emotion/styled";
1497
- import { InputField } from "@xinghunm/compass-ui";
1498
+ import { Input } from "@xinghunm/compass-ui";
1498
1499
 
1499
1500
  // src/components/chat-thread/components/questionnaire-card-helpers.ts
1500
1501
  var OTHER_OPTION_VALUE = "__other__";
@@ -2295,11 +2296,11 @@ var TextInput = styled4.input`
2295
2296
  color: rgba(255, 255, 255, 0.34);
2296
2297
  }
2297
2298
  `;
2298
- var InlineOtherInput = styled4(InputField)`
2299
+ var InlineOtherInput = styled4(Input)`
2299
2300
  width: 100%;
2300
2301
  margin-top: 0;
2301
2302
 
2302
- .compass-input-field-wrapper {
2303
+ .compass-input-wrapper {
2303
2304
  min-height: 30px;
2304
2305
  border: 1px solid rgba(255, 255, 255, 0.1);
2305
2306
  border-radius: 10px;
@@ -2308,22 +2309,22 @@ var InlineOtherInput = styled4(InputField)`
2308
2309
  padding: 2px 9px;
2309
2310
  }
2310
2311
 
2311
- .compass-input-field-wrapper:hover {
2312
+ .compass-input-wrapper:hover {
2312
2313
  border-color: rgba(126, 160, 255, 0.28);
2313
2314
  }
2314
2315
 
2315
- .compass-input-field-wrapper:focus-within {
2316
+ .compass-input-wrapper:focus-within {
2316
2317
  border-color: rgba(126, 160, 255, 0.42);
2317
2318
  box-shadow: 0 0 0 1px rgba(126, 160, 255, 0.14);
2318
2319
  }
2319
2320
 
2320
- .compass-input-field-input {
2321
+ .compass-input-input {
2321
2322
  color: rgba(255, 255, 255, 0.92);
2322
2323
  font-size: 13px;
2323
2324
  line-height: 1.2;
2324
2325
  }
2325
2326
 
2326
- .compass-input-field-input::placeholder {
2327
+ .compass-input-input::placeholder {
2327
2328
  color: rgba(255, 255, 255, 0.34);
2328
2329
  }
2329
2330
  `;
@@ -3317,6 +3318,8 @@ var HeroSubtitle = styled8.p`
3317
3318
 
3318
3319
  // src/components/chat-thread/index.tsx
3319
3320
  import { jsx as jsx10, jsxs as jsxs7 } from "@emotion/react/jsx-runtime";
3321
+ var CHAT_THREAD_PINNED_THRESHOLD_PX = 32;
3322
+ var isThreadPinnedToBottom = (container) => container.scrollHeight - container.clientHeight - container.scrollTop <= CHAT_THREAD_PINNED_THRESHOLD_PX;
3320
3323
  var renderChatMessage = ({
3321
3324
  message,
3322
3325
  mode,
@@ -3390,6 +3393,7 @@ var ChatThreadView = ({
3390
3393
  streamingMessage,
3391
3394
  error,
3392
3395
  retryButtonLabel,
3396
+ scrollToLatestLabel,
3393
3397
  onRetry,
3394
3398
  onConfirmationSubmit,
3395
3399
  onQuestionnaireSubmit,
@@ -3403,9 +3407,15 @@ var ChatThreadView = ({
3403
3407
  const latestTurn = conversationTurns[conversationTurns.length - 1];
3404
3408
  const previousTurns = conversationTurns.slice(0, -1);
3405
3409
  const latestUserMessageId = latestTurn?.userMessage?.id;
3406
- const latestUserMessageRef = useRef5(null);
3410
+ const latestHistoryMessage = historyMessages[historyMessages.length - 1];
3411
+ const latestTurnRef = useRef5(null);
3407
3412
  const reservedSpaceFrameRef = useRef5(null);
3413
+ const isPinnedRef = useRef5(true);
3414
+ const lastHistoryMessageIdRef = useRef5(latestHistoryMessage?.id);
3415
+ const lastStreamingMessageIdRef = useRef5(streamingMessage?.id);
3408
3416
  const [latestTurnMinHeight, setLatestTurnMinHeight] = useState4(0);
3417
+ const [isDetached, setIsDetached] = useState4(false);
3418
+ const [pendingNewMessageCount, setPendingNewMessageCount] = useState4(0);
3409
3419
  const measureLatestTurnMinHeight = useCallback3(() => {
3410
3420
  const container = containerRef.current;
3411
3421
  if (!container)
@@ -3416,26 +3426,86 @@ var ChatThreadView = ({
3416
3426
  const nextMinHeight = Math.max(0, container.clientHeight - paddingTop - paddingBottom);
3417
3427
  setLatestTurnMinHeight((current) => current === nextMinHeight ? current : nextMinHeight);
3418
3428
  }, []);
3419
- const scrollLatestUserMessageToTop = useCallback3(() => {
3429
+ const scrollToBottom = useCallback3((force = false) => {
3420
3430
  const container = containerRef.current;
3421
- const target = latestUserMessageRef.current;
3422
- if (!container || !target)
3423
- return;
3424
- const containerRect = container.getBoundingClientRect();
3425
- const targetRect = target.getBoundingClientRect();
3426
- const nextScrollTop = Math.max(
3427
- 0,
3428
- container.scrollTop + (targetRect.top - containerRect.top) - CHAT_THREAD_SCROLL_TOP_GAP
3429
- );
3431
+ if (!container)
3432
+ return false;
3433
+ if (!force && !isPinnedRef.current)
3434
+ return false;
3435
+ const nextScrollTop = Math.max(0, container.scrollHeight - container.clientHeight);
3436
+ container.scrollTop = nextScrollTop;
3430
3437
  if (typeof container.scrollTo === "function") {
3431
3438
  container.scrollTo({
3432
3439
  top: nextScrollTop,
3433
3440
  behavior: "auto"
3434
3441
  });
3442
+ }
3443
+ return true;
3444
+ }, []);
3445
+ const markThreadPinned = useCallback3(() => {
3446
+ isPinnedRef.current = true;
3447
+ setIsDetached(false);
3448
+ setPendingNewMessageCount(0);
3449
+ }, []);
3450
+ const scrollToBottomAndPin = useCallback3(
3451
+ (force = false) => {
3452
+ const didScroll = scrollToBottom(force);
3453
+ if (!didScroll)
3454
+ return;
3455
+ markThreadPinned();
3456
+ },
3457
+ [markThreadPinned, scrollToBottom]
3458
+ );
3459
+ const handleContainerScroll = useCallback3(() => {
3460
+ const container = containerRef.current;
3461
+ if (!container)
3435
3462
  return;
3463
+ const nextPinned = isThreadPinnedToBottom(container);
3464
+ isPinnedRef.current = nextPinned;
3465
+ setIsDetached(!nextPinned);
3466
+ if (nextPinned) {
3467
+ setPendingNewMessageCount(0);
3436
3468
  }
3437
- container.scrollTop = nextScrollTop;
3438
3469
  }, []);
3470
+ useLayoutEffect2(() => {
3471
+ const nextHistoryMessageId = latestHistoryMessage?.id;
3472
+ if (lastHistoryMessageIdRef.current === nextHistoryMessageId) {
3473
+ return;
3474
+ }
3475
+ lastHistoryMessageIdRef.current = nextHistoryMessageId;
3476
+ if (!latestHistoryMessage) {
3477
+ return;
3478
+ }
3479
+ if (latestHistoryMessage.role === "user") {
3480
+ window.requestAnimationFrame(() => {
3481
+ if (!scrollToBottom(true)) {
3482
+ return;
3483
+ }
3484
+ markThreadPinned();
3485
+ });
3486
+ return;
3487
+ }
3488
+ if (!isPinnedRef.current && latestHistoryMessage.role === "assistant" && latestHistoryMessage.id !== lastStreamingMessageIdRef.current) {
3489
+ window.requestAnimationFrame(() => {
3490
+ setPendingNewMessageCount((current) => current + 1);
3491
+ });
3492
+ }
3493
+ }, [latestHistoryMessage, markThreadPinned, scrollToBottom]);
3494
+ useLayoutEffect2(() => {
3495
+ const nextStreamingMessageId = streamingMessage?.id;
3496
+ if (lastStreamingMessageIdRef.current === nextStreamingMessageId) {
3497
+ return;
3498
+ }
3499
+ lastStreamingMessageIdRef.current = nextStreamingMessageId;
3500
+ if (!streamingMessage || streamingMessage.role !== "assistant") {
3501
+ return;
3502
+ }
3503
+ if (!isPinnedRef.current) {
3504
+ window.requestAnimationFrame(() => {
3505
+ setPendingNewMessageCount((current) => current + 1);
3506
+ });
3507
+ }
3508
+ }, [streamingMessage]);
3439
3509
  useLayoutEffect2(() => {
3440
3510
  if (reservedSpaceFrameRef.current !== null) {
3441
3511
  window.cancelAnimationFrame(reservedSpaceFrameRef.current);
@@ -3445,6 +3515,7 @@ var ChatThreadView = ({
3445
3515
  reservedSpaceFrameRef.current = window.requestAnimationFrame(() => {
3446
3516
  reservedSpaceFrameRef.current = null;
3447
3517
  setLatestTurnMinHeight((current) => current === 0 ? current : 0);
3518
+ scrollToBottom();
3448
3519
  });
3449
3520
  return () => {
3450
3521
  if (reservedSpaceFrameRef.current !== null) {
@@ -3456,7 +3527,7 @@ var ChatThreadView = ({
3456
3527
  reservedSpaceFrameRef.current = window.requestAnimationFrame(() => {
3457
3528
  reservedSpaceFrameRef.current = null;
3458
3529
  measureLatestTurnMinHeight();
3459
- scrollLatestUserMessageToTop();
3530
+ scrollToBottom();
3460
3531
  });
3461
3532
  return () => {
3462
3533
  if (reservedSpaceFrameRef.current !== null) {
@@ -3464,13 +3535,18 @@ var ChatThreadView = ({
3464
3535
  reservedSpaceFrameRef.current = null;
3465
3536
  }
3466
3537
  };
3467
- }, [latestUserMessageId, measureLatestTurnMinHeight, scrollLatestUserMessageToTop]);
3538
+ }, [latestTurn, latestUserMessageId, error, measureLatestTurnMinHeight, scrollToBottom]);
3468
3539
  useLayoutEffect2(() => {
3469
- if (!latestUserMessageId)
3540
+ if (!latestTurn)
3470
3541
  return;
3471
3542
  const handleResize = () => {
3543
+ if (!latestUserMessageId) {
3544
+ setLatestTurnMinHeight((current) => current === 0 ? current : 0);
3545
+ scrollToBottom();
3546
+ return;
3547
+ }
3472
3548
  measureLatestTurnMinHeight();
3473
- scrollLatestUserMessageToTop();
3549
+ scrollToBottom();
3474
3550
  };
3475
3551
  const container = containerRef.current;
3476
3552
  let resizeObserver = null;
@@ -3485,57 +3561,101 @@ var ChatThreadView = ({
3485
3561
  resizeObserver?.disconnect();
3486
3562
  window.removeEventListener("resize", handleResize);
3487
3563
  };
3488
- }, [latestUserMessageId, measureLatestTurnMinHeight, scrollLatestUserMessageToTop]);
3489
- return /* @__PURE__ */ jsxs7(Container, { ref: containerRef, "data-testid": "chat-thread", children: [
3490
- previousTurns.map((turn) => /* @__PURE__ */ jsxs7(ConversationTurn, { "data-testid": "chat-thread-turn", children: [
3491
- turn.userMessage ? /* @__PURE__ */ jsx10(MessageSlot, { children: renderChatMessage({
3492
- message: turn.userMessage,
3493
- mode: activeSessionMode,
3494
- onConfirmationSubmit,
3495
- onQuestionnaireSubmit,
3496
- renderMessageBlock
3497
- }) }) : null,
3498
- turn.responseMessages.map((message) => /* @__PURE__ */ jsx10(MessageSlot, { children: renderChatMessage({
3499
- message,
3500
- mode: activeSessionMode,
3501
- onConfirmationSubmit,
3502
- onQuestionnaireSubmit,
3503
- renderMessageBlock
3504
- }) }, message.id))
3505
- ] }, turn.id)),
3506
- latestTurn ? /* @__PURE__ */ jsxs7(
3507
- ConversationTurn,
3564
+ }, [latestTurn, latestUserMessageId, measureLatestTurnMinHeight, scrollToBottom]);
3565
+ useLayoutEffect2(() => {
3566
+ const latestTurnElement = latestTurnRef.current;
3567
+ if (!latestTurnElement || typeof ResizeObserver === "undefined") {
3568
+ return;
3569
+ }
3570
+ const observer = new ResizeObserver(() => {
3571
+ scrollToBottom();
3572
+ });
3573
+ observer.observe(latestTurnElement);
3574
+ return () => {
3575
+ observer.disconnect();
3576
+ };
3577
+ }, [latestTurn, scrollToBottom]);
3578
+ useLayoutEffect2(() => {
3579
+ const latestTurnElement = latestTurnRef.current;
3580
+ if (!latestTurnElement || typeof MutationObserver === "undefined") {
3581
+ return;
3582
+ }
3583
+ const observer = new MutationObserver(() => {
3584
+ scrollToBottom();
3585
+ });
3586
+ observer.observe(latestTurnElement, {
3587
+ childList: true,
3588
+ subtree: true,
3589
+ characterData: true
3590
+ });
3591
+ return () => {
3592
+ observer.disconnect();
3593
+ };
3594
+ }, [latestTurn, scrollToBottom]);
3595
+ return /* @__PURE__ */ jsxs7(ThreadViewport, { children: [
3596
+ /* @__PURE__ */ jsxs7(Container, { ref: containerRef, "data-testid": "chat-thread", onScroll: handleContainerScroll, children: [
3597
+ previousTurns.map((turn) => /* @__PURE__ */ jsxs7(ConversationTurn, { "data-testid": "chat-thread-turn", children: [
3598
+ turn.userMessage ? /* @__PURE__ */ jsx10(MessageSlot, { children: renderChatMessage({
3599
+ message: turn.userMessage,
3600
+ mode: activeSessionMode,
3601
+ onConfirmationSubmit,
3602
+ onQuestionnaireSubmit,
3603
+ renderMessageBlock
3604
+ }) }) : null,
3605
+ turn.responseMessages.map((message) => /* @__PURE__ */ jsx10(MessageSlot, { children: renderChatMessage({
3606
+ message,
3607
+ mode: activeSessionMode,
3608
+ onConfirmationSubmit,
3609
+ onQuestionnaireSubmit,
3610
+ renderMessageBlock
3611
+ }) }, message.id))
3612
+ ] }, turn.id)),
3613
+ latestTurn ? /* @__PURE__ */ jsxs7(
3614
+ ConversationTurn,
3615
+ {
3616
+ ref: latestTurnRef,
3617
+ "data-testid": "chat-thread-latest-turn",
3618
+ style: latestTurnMinHeight > 0 ? { minHeight: `${latestTurnMinHeight}px` } : void 0,
3619
+ children: [
3620
+ latestTurn.userMessage ? /* @__PURE__ */ jsx10(
3621
+ MessageSlot,
3622
+ {
3623
+ "data-testid": "chat-latest-user-anchor",
3624
+ style: { scrollMarginTop: `${CHAT_THREAD_SCROLL_TOP_GAP}px` },
3625
+ children: renderChatMessage({
3626
+ message: latestTurn.userMessage,
3627
+ mode: activeSessionMode,
3628
+ onConfirmationSubmit,
3629
+ onQuestionnaireSubmit,
3630
+ renderMessageBlock
3631
+ })
3632
+ }
3633
+ ) : null,
3634
+ latestTurn.responseMessages.map((message) => /* @__PURE__ */ jsx10(MessageSlot, { children: renderChatMessage({
3635
+ message,
3636
+ mode: activeSessionMode,
3637
+ onConfirmationSubmit,
3638
+ onQuestionnaireSubmit,
3639
+ renderMessageBlock
3640
+ }) }, message.id)),
3641
+ error ? renderErrorState({ error, onRetry, retryButtonLabel }) : null
3642
+ ]
3643
+ }
3644
+ ) : null,
3645
+ !latestTurn && error ? renderErrorState({ error, onRetry, retryButtonLabel }) : null
3646
+ ] }),
3647
+ isDetached ? /* @__PURE__ */ jsx10(ScrollToLatestOverlay, { children: /* @__PURE__ */ jsxs7(
3648
+ ScrollToLatestButton,
3508
3649
  {
3509
- "data-testid": "chat-thread-latest-turn",
3510
- style: latestTurnMinHeight > 0 ? { minHeight: `${latestTurnMinHeight}px` } : void 0,
3650
+ type: "button",
3651
+ "data-testid": "chat-thread-scroll-to-latest",
3652
+ onClick: () => scrollToBottomAndPin(true),
3511
3653
  children: [
3512
- latestTurn.userMessage ? /* @__PURE__ */ jsx10(
3513
- MessageSlot,
3514
- {
3515
- ref: latestUserMessageRef,
3516
- "data-testid": "chat-latest-user-anchor",
3517
- style: { scrollMarginTop: `${CHAT_THREAD_SCROLL_TOP_GAP}px` },
3518
- children: renderChatMessage({
3519
- message: latestTurn.userMessage,
3520
- mode: activeSessionMode,
3521
- onConfirmationSubmit,
3522
- onQuestionnaireSubmit,
3523
- renderMessageBlock
3524
- })
3525
- }
3526
- ) : null,
3527
- latestTurn.responseMessages.map((message) => /* @__PURE__ */ jsx10(MessageSlot, { children: renderChatMessage({
3528
- message,
3529
- mode: activeSessionMode,
3530
- onConfirmationSubmit,
3531
- onQuestionnaireSubmit,
3532
- renderMessageBlock
3533
- }) }, message.id)),
3534
- error ? renderErrorState({ error, onRetry, retryButtonLabel }) : null
3654
+ scrollToLatestLabel,
3655
+ pendingNewMessageCount > 0 ? /* @__PURE__ */ jsx10(ScrollToLatestBadge, { "data-testid": "chat-thread-scroll-to-latest-count", children: pendingNewMessageCount }) : null
3535
3656
  ]
3536
3657
  }
3537
- ) : null,
3538
- !latestTurn && error ? renderErrorState({ error, onRetry, retryButtonLabel }) : null
3658
+ ) }) : null
3539
3659
  ] });
3540
3660
  };
3541
3661
  var EMPTY_MESSAGES = [];
@@ -3623,13 +3743,21 @@ var ChatThread = () => {
3623
3743
  streamingMessage,
3624
3744
  error,
3625
3745
  retryButtonLabel: labels.retryButton,
3746
+ scrollToLatestLabel: labels.scrollToLatest,
3626
3747
  onRetry: handleRetry,
3627
3748
  onConfirmationSubmit: handleConfirmation,
3628
3749
  onQuestionnaireSubmit: handleQuestionnaireSubmit,
3629
3750
  renderMessageBlock
3630
- }
3751
+ },
3752
+ activeSessionId ?? "chat-thread-empty"
3631
3753
  );
3632
3754
  };
3755
+ var ThreadViewport = styled9.div`
3756
+ position: relative;
3757
+ display: flex;
3758
+ flex: 1;
3759
+ min-height: 0;
3760
+ `;
3633
3761
  var Container = styled9.div`
3634
3762
  display: flex;
3635
3763
  flex: 1;
@@ -3637,8 +3765,7 @@ var Container = styled9.div`
3637
3765
  gap: 18px;
3638
3766
  min-height: 0;
3639
3767
  overflow: auto;
3640
- padding: 24px;
3641
- margin-bottom: 24px;
3768
+ padding: 24px 24px 88px;
3642
3769
  overscroll-behavior: contain;
3643
3770
 
3644
3771
  &::-webkit-scrollbar {
@@ -3690,6 +3817,48 @@ var RetryButton = styled9.button`
3690
3817
  background: rgba(255, 255, 255, 0.08);
3691
3818
  }
3692
3819
  `;
3820
+ var ScrollToLatestOverlay = styled9.div`
3821
+ position: absolute;
3822
+ right: 24px;
3823
+ bottom: 24px;
3824
+ left: 24px;
3825
+ display: flex;
3826
+ justify-content: center;
3827
+ pointer-events: none;
3828
+ `;
3829
+ var ScrollToLatestButton = styled9.button`
3830
+ display: inline-flex;
3831
+ align-items: center;
3832
+ gap: 8px;
3833
+ border: 1px solid rgba(255, 255, 255, 0.14);
3834
+ border-radius: 999px;
3835
+ background: rgba(17, 18, 21, 0.92);
3836
+ color: rgba(255, 255, 255, 0.9);
3837
+ font-size: 12px;
3838
+ line-height: 1;
3839
+ padding: 10px 14px;
3840
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
3841
+ cursor: pointer;
3842
+ z-index: 1;
3843
+ pointer-events: auto;
3844
+
3845
+ &:hover {
3846
+ background: rgba(28, 30, 36, 0.96);
3847
+ }
3848
+ `;
3849
+ var ScrollToLatestBadge = styled9.span`
3850
+ display: inline-flex;
3851
+ min-width: 18px;
3852
+ height: 18px;
3853
+ align-items: center;
3854
+ justify-content: center;
3855
+ padding: 0 6px;
3856
+ border-radius: 999px;
3857
+ background: rgba(109, 170, 255, 0.2);
3858
+ color: #9ac0ff;
3859
+ font-size: 11px;
3860
+ font-weight: 600;
3861
+ `;
3693
3862
 
3694
3863
  // src/components/chat-composer/index.tsx
3695
3864
  import { useEffect as useEffect7, useLayoutEffect as useLayoutEffect3, useRef as useRef8, useState as useState8 } from "react";
@@ -4897,7 +5066,7 @@ var ChatComposerView = ({
4897
5066
  }
4898
5067
  ) : null,
4899
5068
  /* @__PURE__ */ jsx15(
4900
- Input,
5069
+ Input2,
4901
5070
  {
4902
5071
  ref: inputRef,
4903
5072
  "data-testid": "chat-composer-input",
@@ -5041,7 +5210,7 @@ var InputArea = styled14.div`
5041
5210
  grid-area: input;
5042
5211
  position: relative;
5043
5212
  `;
5044
- var Input = styled14.textarea`
5213
+ var Input2 = styled14.textarea`
5045
5214
  --textarea-line-height: ${CHAT_COMPOSER_LINE_HEIGHT_PX}px;
5046
5215
  --textarea-min-rows: ${CHAT_COMPOSER_MIN_ROWS};
5047
5216
  --textarea-max-rows: ${CHAT_COMPOSER_MAX_ROWS};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xinghunm/ai-chat",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "AI chat React component library",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -19,7 +19,7 @@
19
19
  "peerDependencies": {
20
20
  "@emotion/react": ">=11",
21
21
  "@emotion/styled": ">=11",
22
- "@xinghunm/compass-ui": ">=0.8.0",
22
+ "@xinghunm/compass-ui": ">=0.9.0",
23
23
  "axios": ">=1.0",
24
24
  "react": ">=18",
25
25
  "react-dom": ">=18",
@@ -53,7 +53,7 @@
53
53
  "tsup": "^8.0.0",
54
54
  "typescript": "^5.2.2",
55
55
  "zustand": "^5.0.0",
56
- "@xinghunm/compass-ui": "^0.8.3"
56
+ "@xinghunm/compass-ui": "^0.9.0"
57
57
  },
58
58
  "publishConfig": {
59
59
  "access": "public"