cortena-ui 1.6.0 → 1.7.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.
- package/CHANGELOG.md +76 -0
- package/README.md +36 -0
- package/dist/agent-chat/session.js +21 -7
- package/dist/agent-chat/session.js.map +1 -1
- package/dist/agent-chat/store.d.ts +55 -4
- package/dist/agent-chat/store.js +87 -22
- package/dist/agent-chat/store.js.map +1 -1
- package/dist/agent-chat/types.d.ts +11 -0
- package/dist/agent-chat/types.js.map +1 -1
- package/dist/agent-chat.d.ts +3 -3
- package/dist/components/agent-chat-popup.d.ts +2 -2
- package/dist/components/agent-chat-popup.js.map +1 -1
- package/dist/components/agent-chat.d.ts +169 -13
- package/dist/components/agent-chat.js +200 -27
- package/dist/components/agent-chat.js.map +1 -1
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/toast.d.ts +1 -1
- package/package.json +1 -1
- package/src/agent-chat/session.ts +35 -5
- package/src/agent-chat/store.ts +193 -21
- package/src/agent-chat/types.ts +11 -0
- package/src/components/agent-chat-popup.tsx +3 -3
- package/src/components/agent-chat.tsx +576 -149
- package/src/entries/agent-chat.ts +12 -0
|
@@ -44,8 +44,9 @@ import { Spinner } from "@/components/spinner";
|
|
|
44
44
|
import { Textarea } from "@/components/textarea";
|
|
45
45
|
import { isHiddenMessage, STORED_TEXT_LIMIT } from "@/agent-chat/session";
|
|
46
46
|
import { isGenericLabel, truncate } from "@/agent-chat/step-label";
|
|
47
|
-
import { type AgentToolEntry, useAgentChat } from "@/agent-chat/store";
|
|
47
|
+
import { type AgentToolEntry, useAgentChat, type UseAgentChatResult } from "@/agent-chat/store";
|
|
48
48
|
import type {
|
|
49
|
+
AgentApprovalDecision,
|
|
49
50
|
AgentApprovalRequest,
|
|
50
51
|
AgentChatClient,
|
|
51
52
|
AgentChatMessage,
|
|
@@ -58,6 +59,14 @@ import { cn } from "@/lib/cn";
|
|
|
58
59
|
|
|
59
60
|
/* ── stick to bottom ─────────────────────────────────────────────────────── */
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* `useLayoutEffect` in a browser, `useEffect` on a server render, where React
|
|
64
|
+
* warns about the former. The scroll restore after a prepend has to run
|
|
65
|
+
* before the paint: an effect after it shows one frame at the old `scrollTop`,
|
|
66
|
+
* with the page that just landed pushing the reader's place off the screen.
|
|
67
|
+
*/
|
|
68
|
+
const useLayoutEffectSafe = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
|
|
69
|
+
|
|
61
70
|
/**
|
|
62
71
|
* Should the list jump back to the bottom after its content grew?
|
|
63
72
|
*
|
|
@@ -620,19 +629,129 @@ export interface AgentToolRenderContext {
|
|
|
620
629
|
mcpAppUri: string | null;
|
|
621
630
|
}
|
|
622
631
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
632
|
+
/* ── the slots a host may fill ───────────────────────────────────────────── */
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* What a host's own message bubble receives. `index` and `messages` are over
|
|
636
|
+
* the VISIBLE messages — the filter chain has already run — so
|
|
637
|
+
* `messages[index - 1]` is the turn before this one, which a feedback control
|
|
638
|
+
* or a transcript export wants.
|
|
639
|
+
*/
|
|
640
|
+
export interface AgentChatMessageSlotProps {
|
|
641
|
+
message: AgentChatMessage;
|
|
642
|
+
index: number;
|
|
643
|
+
messages: readonly AgentChatMessage[];
|
|
644
|
+
agentName: string;
|
|
645
|
+
sessionKey: string | null;
|
|
646
|
+
/** True while a run is live; every A2UI surface goes read-only. */
|
|
647
|
+
streaming: boolean;
|
|
648
|
+
onAction: (action: A2UIAction) => void;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export interface AgentChatStreamingSlotProps {
|
|
627
652
|
agentName: string;
|
|
653
|
+
text: string;
|
|
654
|
+
thinkingText: string;
|
|
655
|
+
a2ui: readonly A2UIChatBlock[];
|
|
656
|
+
/** The turn's tool calls in plain words; the default draws them under the name. */
|
|
657
|
+
steps: readonly AgentChatStep[];
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export interface AgentChatEmptySlotProps {
|
|
661
|
+
agentName: string;
|
|
662
|
+
/** A history load is in flight for this session; a skeleton fits here. */
|
|
663
|
+
loadingHistory: boolean;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export interface AgentChatLoadOlderSlotProps {
|
|
667
|
+
hasMore: boolean;
|
|
668
|
+
loading: boolean;
|
|
669
|
+
onLoad: () => void;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export interface AgentChatToolsSlotProps {
|
|
673
|
+
entries: readonly AgentToolEntry[];
|
|
674
|
+
approvals: readonly AgentApprovalRequest[];
|
|
675
|
+
streaming: boolean;
|
|
628
676
|
/**
|
|
629
|
-
*
|
|
630
|
-
*
|
|
677
|
+
* The host's `renderToolCall`, as it was passed: `undefined` for a call the
|
|
678
|
+
* host declines, so the slot can draw its own line for those and still mount
|
|
679
|
+
* an MCP App through the one seam the component defines.
|
|
631
680
|
*/
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
681
|
+
renderToolCall: ((context: AgentToolRenderContext) => React.ReactNode | undefined) | undefined;
|
|
682
|
+
onResolveApproval: (id: string, decision: AgentApprovalDecision) => void;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export interface AgentChatComposerSlotProps {
|
|
686
|
+
agentName: string;
|
|
687
|
+
streaming: boolean;
|
|
688
|
+
send: UseAgentChatResult["send"];
|
|
689
|
+
abort: () => void;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Renders a host may substitute for the component's own, one region each.
|
|
694
|
+
*
|
|
695
|
+
* The surface — its state, the order of its regions, the scroller that stays
|
|
696
|
+
* pinned to the bottom, the sentinel that pages history, the steps and the
|
|
697
|
+
* tool-card seam — is shared; what each region LOOKS like need not be. This is
|
|
698
|
+
* how cortenaweb draws its own bubble (Shiki, KaTeX, feedback controls, a
|
|
699
|
+
* timestamp chip) and its own composer (attachments, agent and model
|
|
700
|
+
* selectors) on the shared surface instead of keeping a second chat
|
|
701
|
+
* (PLATFORM-64).
|
|
702
|
+
*
|
|
703
|
+
* A component given here replaces its region whole, wrapper included; nothing
|
|
704
|
+
* of the default is drawn around it.
|
|
705
|
+
*/
|
|
706
|
+
export interface AgentChatComponents {
|
|
707
|
+
Message: React.ComponentType<AgentChatMessageSlotProps>;
|
|
708
|
+
/**
|
|
709
|
+
* The live bubble. It is handed `steps` because the default draws the
|
|
710
|
+
* progress steps INSIDE the streaming bubble; a host's own bubble draws them
|
|
711
|
+
* itself, or not at all — nothing else draws them while a run is live.
|
|
712
|
+
*/
|
|
713
|
+
Streaming: React.ComponentType<AgentChatStreamingSlotProps>;
|
|
714
|
+
/**
|
|
715
|
+
* Replaces the whole transcript region while there is nothing to show. Not
|
|
716
|
+
* while there is an error: the transcript then mounts with the error banner
|
|
717
|
+
* in it, so a failure before the first message is not hidden behind the
|
|
718
|
+
* host's welcome screen.
|
|
719
|
+
*/
|
|
720
|
+
Empty: React.ComponentType<AgentChatEmptySlotProps>;
|
|
721
|
+
/** Drawn at the top of the transcript while there is, or may be, more. */
|
|
722
|
+
LoadOlder: React.ComponentType<AgentChatLoadOlderSlotProps>;
|
|
723
|
+
/** Replaces the tool strip and the approval cards at the foot. */
|
|
724
|
+
Tools: React.ComponentType<AgentChatToolsSlotProps>;
|
|
725
|
+
Composer: React.ComponentType<AgentChatComposerSlotProps>;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Class names for the wrappers a host cannot otherwise reach.
|
|
730
|
+
*
|
|
731
|
+
* `transcript` REPLACES the default spacing (`gap-5 px-4 py-4`) rather than
|
|
732
|
+
* merging with it: a reading column sets its own gutter through a class, and a
|
|
733
|
+
* utility left on the element would fight it. The structural classes — flex,
|
|
734
|
+
* min-width — stay in either case.
|
|
735
|
+
*/
|
|
736
|
+
export interface AgentChatClassNames {
|
|
737
|
+
header?: string;
|
|
738
|
+
scroll?: string;
|
|
739
|
+
transcript?: string;
|
|
740
|
+
/**
|
|
741
|
+
* The history sentinel at the top of the transcript. Out of the flow by
|
|
742
|
+
* default (`absolute inset-x-0 top-0 h-px`), so it adds no space; a host
|
|
743
|
+
* whose column already reserved a row for it passes an in-flow class.
|
|
744
|
+
*/
|
|
745
|
+
sentinel?: string;
|
|
746
|
+
tools?: string;
|
|
747
|
+
composer?: string;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** What every `AgentChat` takes, however its state is owned. */
|
|
751
|
+
export interface AgentChatBaseProps
|
|
752
|
+
extends Omit<React.ComponentProps<"div">, "children" | "onError"> {
|
|
753
|
+
/** The extension name, shown on each assistant turn. */
|
|
754
|
+
agentName: string;
|
|
636
755
|
/** Rendered between the tool strip and the composer — an attachment picker. */
|
|
637
756
|
attachmentsSlot?: React.ReactNode;
|
|
638
757
|
/**
|
|
@@ -643,31 +762,154 @@ export interface AgentChatProps extends Omit<React.ComponentProps<"div">, "child
|
|
|
643
762
|
renderToolCall?: (context: AgentToolRenderContext) => React.ReactNode | undefined;
|
|
644
763
|
/** Header content, e.g. the pop-up's own window controls. */
|
|
645
764
|
headerSlot?: React.ReactNode;
|
|
765
|
+
/** No header at all — for a host with its own session list and New button. */
|
|
766
|
+
hideHeader?: boolean;
|
|
767
|
+
/** The host's own renders for each region. See `AgentChatComponents`. */
|
|
768
|
+
components?: Partial<AgentChatComponents>;
|
|
769
|
+
/** Classes for the wrappers. See `AgentChatClassNames`. */
|
|
770
|
+
classNames?: AgentChatClassNames;
|
|
771
|
+
/**
|
|
772
|
+
* Page older history from the sentinel at the top of the transcript as the
|
|
773
|
+
* reader reaches it, not only from the `LoadOlder` button. Off by default —
|
|
774
|
+
* the pop-up pages on a click. When on, it is armed only once the scroller
|
|
775
|
+
* overflows: a transcript shorter than its viewport has its sentinel
|
|
776
|
+
* permanently in view, and paging on that pulled every page the server had
|
|
777
|
+
* into a chat with three messages in it.
|
|
778
|
+
*/
|
|
779
|
+
autoLoadOlder?: boolean;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* The surface owning its own state: it calls `useAgentChat` on the client it is
|
|
784
|
+
* given. This is what `AgentChatPopup` mounts.
|
|
785
|
+
*/
|
|
786
|
+
export interface AgentChatOwnedProps extends AgentChatBaseProps {
|
|
787
|
+
/** The transport. `createAguiAgentChatClient` is the AG-UI implementation. */
|
|
788
|
+
client: AgentChatClient;
|
|
789
|
+
/**
|
|
790
|
+
* Names the `sessionStorage` slot this window's session key lives in. Scope
|
|
791
|
+
* it to the extension id; two chats with different scopes never share a key.
|
|
792
|
+
*/
|
|
793
|
+
storageScope: string;
|
|
794
|
+
/** Do not read or write `sessionStorage`; for a guide or a test. */
|
|
795
|
+
ephemeral?: boolean;
|
|
796
|
+
thinking?: "low" | "medium" | "high";
|
|
797
|
+
chat?: undefined;
|
|
646
798
|
}
|
|
647
799
|
|
|
648
|
-
|
|
800
|
+
/**
|
|
801
|
+
* The surface drawing state the host owns.
|
|
802
|
+
*
|
|
803
|
+
* A host that needs the state OUTSIDE the surface — a sidebar that lists
|
|
804
|
+
* sessions, a shortcut that stops the run, a page that routes on the session
|
|
805
|
+
* key — holds it itself and passes a `UseAgentChatResult` here; the surface
|
|
806
|
+
* then draws that state instead of holding its own. The result may come from
|
|
807
|
+
* `useAgentChat`, or from a store of the host's own projected onto the same
|
|
808
|
+
* shape, which is how cortenaweb drives this surface from the zustand stores
|
|
809
|
+
* the rest of its shell reads (PLATFORM-64). No client, no storage: the host
|
|
810
|
+
* owns both.
|
|
811
|
+
*/
|
|
812
|
+
export interface AgentChatHostedProps extends AgentChatBaseProps {
|
|
813
|
+
chat: UseAgentChatResult;
|
|
814
|
+
client?: undefined;
|
|
815
|
+
storageScope?: undefined;
|
|
816
|
+
ephemeral?: undefined;
|
|
817
|
+
thinking?: undefined;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export type AgentChatProps = AgentChatOwnedProps | AgentChatHostedProps;
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Two ways in, one surface. The hook is called in a component of its own so
|
|
824
|
+
* a host that hands over `chat` never calls it — and so a mount that switches
|
|
825
|
+
* from one mode to the other remounts rather than changing its hook order.
|
|
826
|
+
*/
|
|
827
|
+
function AgentChat(props: AgentChatProps) {
|
|
828
|
+
if (props.chat) {
|
|
829
|
+
const { chat, client: _client, storageScope: _scope, ephemeral: _e, thinking: _t, ...rest } =
|
|
830
|
+
props;
|
|
831
|
+
return <AgentChatSurface {...rest} chat={chat} />;
|
|
832
|
+
}
|
|
833
|
+
return <AgentChatOwned {...props} />;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function AgentChatOwned({
|
|
649
837
|
client,
|
|
650
|
-
agentName,
|
|
651
838
|
storageScope,
|
|
652
839
|
ephemeral,
|
|
653
840
|
thinking,
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
className,
|
|
658
|
-
...props
|
|
659
|
-
}: AgentChatProps) {
|
|
841
|
+
chat: _chat,
|
|
842
|
+
...rest
|
|
843
|
+
}: AgentChatOwnedProps) {
|
|
660
844
|
const chat = useAgentChat({
|
|
661
845
|
client,
|
|
662
846
|
storageScope,
|
|
663
847
|
...(ephemeral === undefined ? {} : { ephemeral }),
|
|
664
848
|
...(thinking === undefined ? {} : { thinking }),
|
|
665
849
|
});
|
|
850
|
+
return <AgentChatSurface {...rest} chat={chat} />;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** The default at the top of the transcript: a button, when there is more. */
|
|
854
|
+
function DefaultLoadOlder({ hasMore, loading, onLoad }: AgentChatLoadOlderSlotProps) {
|
|
855
|
+
if (!hasMore) {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
return (
|
|
859
|
+
<div className="flex justify-center">
|
|
860
|
+
<Button
|
|
861
|
+
size="sm"
|
|
862
|
+
variant="ghost"
|
|
863
|
+
data-slot="agent-chat-load-older"
|
|
864
|
+
disabled={loading}
|
|
865
|
+
onClick={onLoad}
|
|
866
|
+
>
|
|
867
|
+
{loading ? "Loading…" : "Load older messages"}
|
|
868
|
+
</Button>
|
|
869
|
+
</div>
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** Distance from the bottom, in pixels, within which new content re-pins the view. */
|
|
874
|
+
const NEAR_BOTTOM_PX = 100;
|
|
875
|
+
|
|
876
|
+
function AgentChatSurface({
|
|
877
|
+
chat,
|
|
878
|
+
agentName,
|
|
879
|
+
attachmentsSlot,
|
|
880
|
+
renderToolCall,
|
|
881
|
+
headerSlot,
|
|
882
|
+
hideHeader,
|
|
883
|
+
components,
|
|
884
|
+
classNames,
|
|
885
|
+
autoLoadOlder = false,
|
|
886
|
+
className,
|
|
887
|
+
...props
|
|
888
|
+
}: AgentChatBaseProps & { chat: UseAgentChatResult }) {
|
|
666
889
|
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
|
667
890
|
const [value, setValue] = React.useState("");
|
|
668
891
|
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
669
892
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
|
893
|
+
const sentinelRef = React.useRef<HTMLDivElement>(null);
|
|
670
894
|
const prevHeightRef = React.useRef(0);
|
|
895
|
+
/** Whether the reader was at the bottom at the last scroll; the smooth re-pin reads it. */
|
|
896
|
+
const nearBottomRef = React.useRef(true);
|
|
897
|
+
/** The scroll height saved before a page of older history is prepended; 0 when none is in flight. */
|
|
898
|
+
const prependHeightRef = React.useRef(0);
|
|
899
|
+
/** Set by a prepend restore; the resize observer sits out the one notification the prepend produces. */
|
|
900
|
+
const holdRef = React.useRef(false);
|
|
901
|
+
const prevMessageCountRef = React.useRef(0);
|
|
902
|
+
|
|
903
|
+
const visible = chat.messages.filter((message) => !isHiddenMessage(message));
|
|
904
|
+
const Empty = components?.Empty;
|
|
905
|
+
// The transcript region is replaced whole by a host's Empty while there is
|
|
906
|
+
// nothing to show — a skeleton wants the height the scroller would have had,
|
|
907
|
+
// and a paragraph inside the column cannot take it.
|
|
908
|
+
// Not while there is an error, either: with a host's Empty holding the region
|
|
909
|
+
// the banner would have nowhere to be drawn, and the failure would be hidden
|
|
910
|
+
// behind a welcome screen.
|
|
911
|
+
const showEmpty = visible.length === 0 && !chat.streaming && !chat.error;
|
|
912
|
+
const showTranscript = !(showEmpty && Empty);
|
|
671
913
|
|
|
672
914
|
/**
|
|
673
915
|
* Re-pin to the bottom whenever the content grows or the viewport shrinks.
|
|
@@ -677,6 +919,10 @@ function AgentChat({
|
|
|
677
919
|
*
|
|
678
920
|
* Jumping rather than smooth-scrolling: this fires many times a second during
|
|
679
921
|
* streaming, and queued smooth animations fight each other and lose.
|
|
922
|
+
*
|
|
923
|
+
* Keyed on `showTranscript`: the scroller is unmounted while a host's Empty
|
|
924
|
+
* holds the region, and the observer has to attach to the one that mounts
|
|
925
|
+
* when the first message arrives.
|
|
680
926
|
*/
|
|
681
927
|
React.useEffect(() => {
|
|
682
928
|
const scrollEl = scrollRef.current;
|
|
@@ -685,6 +931,15 @@ function AgentChat({
|
|
|
685
931
|
return;
|
|
686
932
|
}
|
|
687
933
|
const observer = new ResizeObserver(() => {
|
|
934
|
+
if (holdRef.current) {
|
|
935
|
+
// The growth was a prepend, and the layout effect below has already
|
|
936
|
+
// put the reader back where they were. Judging it against the height
|
|
937
|
+
// before the page landed would read as "the reader is near the
|
|
938
|
+
// bottom" and undo the hold.
|
|
939
|
+
holdRef.current = false;
|
|
940
|
+
prevHeightRef.current = scrollEl.scrollHeight;
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
688
943
|
const { scrollTop, scrollHeight, clientHeight } = scrollEl;
|
|
689
944
|
const stick = shouldStickToBottom({
|
|
690
945
|
prevScrollHeight: prevHeightRef.current,
|
|
@@ -698,8 +953,111 @@ function AgentChat({
|
|
|
698
953
|
});
|
|
699
954
|
observer.observe(contentEl);
|
|
700
955
|
observer.observe(scrollEl);
|
|
956
|
+
const onScroll = () => {
|
|
957
|
+
nearBottomRef.current =
|
|
958
|
+
scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < NEAR_BOTTOM_PX;
|
|
959
|
+
};
|
|
960
|
+
scrollEl.addEventListener("scroll", onScroll, { passive: true });
|
|
961
|
+
return () => {
|
|
962
|
+
observer.disconnect();
|
|
963
|
+
scrollEl.removeEventListener("scroll", onScroll);
|
|
964
|
+
};
|
|
965
|
+
}, [showTranscript]);
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Page older history as the reader reaches the top, not only on a click —
|
|
969
|
+
* when the host asks for it with `autoLoadOlder`. The sentinel is the first
|
|
970
|
+
* child of the transcript; when it scrolls into the viewport and there is
|
|
971
|
+
* more, the next page is asked for.
|
|
972
|
+
*/
|
|
973
|
+
// Keyed on the METHOD, not on `chat`. The hook returns a fresh result object
|
|
974
|
+
// every render, so depending on `chat` re-created this callback — and
|
|
975
|
+
// re-attached the observer below — on every keystroke of a stream.
|
|
976
|
+
const loadOlderChat = chat.loadOlder;
|
|
977
|
+
const loadOlder = React.useCallback(() => {
|
|
978
|
+
const scrollEl = scrollRef.current;
|
|
979
|
+
// Measured BEFORE the call, and kept only when a load really started. The
|
|
980
|
+
// store declines while one is in flight, when there is no more and when
|
|
981
|
+
// there is no session; a height saved on a declined call was restored
|
|
982
|
+
// against the next thing that grew the transcript — a new message — and
|
|
983
|
+
// shoved the reader up by its height.
|
|
984
|
+
const height = scrollEl?.scrollHeight ?? 0;
|
|
985
|
+
if (loadOlderChat() === true && height > 0) {
|
|
986
|
+
prependHeightRef.current = height;
|
|
987
|
+
}
|
|
988
|
+
}, [loadOlderChat]);
|
|
989
|
+
|
|
990
|
+
React.useEffect(() => {
|
|
991
|
+
const sentinel = sentinelRef.current;
|
|
992
|
+
const scrollEl = scrollRef.current;
|
|
993
|
+
if (!autoLoadOlder || !sentinel || !scrollEl || typeof IntersectionObserver === "undefined") {
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const observer = new IntersectionObserver(
|
|
997
|
+
(entries) => {
|
|
998
|
+
if (!entries[0]?.isIntersecting) {
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
// Armed only once the scroller overflows. Before that the sentinel is
|
|
1002
|
+
// simply always in view, and "in view" means nothing about the reader
|
|
1003
|
+
// having gone looking for what came before.
|
|
1004
|
+
if (scrollEl.scrollHeight <= scrollEl.clientHeight) {
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (chat.hasMoreHistory && !chat.loadingHistory) {
|
|
1008
|
+
loadOlder();
|
|
1009
|
+
}
|
|
1010
|
+
},
|
|
1011
|
+
{ root: scrollEl, threshold: 0.1 },
|
|
1012
|
+
);
|
|
1013
|
+
observer.observe(sentinel);
|
|
701
1014
|
return () => observer.disconnect();
|
|
702
|
-
}, []);
|
|
1015
|
+
}, [autoLoadOlder, chat.hasMoreHistory, chat.loadingHistory, loadOlder, showTranscript]);
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* After a prepend, put the reader back where they were; after an append,
|
|
1019
|
+
* follow the newest message — smoothly, once per message rather than per
|
|
1020
|
+
* resize, which is why this is separate from the observer above and why it
|
|
1021
|
+
* is NOT keyed on the stream text: growth during a run is the observer's,
|
|
1022
|
+
* and a smooth scroll queued per delta fought it and lost.
|
|
1023
|
+
*
|
|
1024
|
+
* A layout effect, so the restore lands before the paint; an effect after
|
|
1025
|
+
* it drew one frame at the old `scrollTop` with the new page on top.
|
|
1026
|
+
*/
|
|
1027
|
+
useLayoutEffectSafe(() => {
|
|
1028
|
+
const scrollEl = scrollRef.current;
|
|
1029
|
+
if (!scrollEl) {
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
const prevCount = prevMessageCountRef.current;
|
|
1033
|
+
const count = chat.messages.length;
|
|
1034
|
+
prevMessageCountRef.current = count;
|
|
1035
|
+
const saved = prependHeightRef.current;
|
|
1036
|
+
if (saved > 0 && !chat.loadingHistory) {
|
|
1037
|
+
// The load that saved this height is over — with a page, or without
|
|
1038
|
+
// one (a failure, or the store declining after all). Either way the
|
|
1039
|
+
// height is spent; it must not be restored against a later append.
|
|
1040
|
+
prependHeightRef.current = 0;
|
|
1041
|
+
if (count > prevCount && prevCount > 0) {
|
|
1042
|
+
const delta = scrollEl.scrollHeight - saved;
|
|
1043
|
+
if (delta > 0) {
|
|
1044
|
+
scrollEl.scrollTop += delta;
|
|
1045
|
+
prevHeightRef.current = scrollEl.scrollHeight;
|
|
1046
|
+
holdRef.current = true;
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
if (count !== prevCount && nearBottomRef.current) {
|
|
1052
|
+
// jsdom has no `scrollTo` on elements, and a host's page test renders
|
|
1053
|
+
// this surface under jsdom; the jump is the honest fallback there.
|
|
1054
|
+
if (typeof scrollEl.scrollTo === "function") {
|
|
1055
|
+
scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: "smooth" });
|
|
1056
|
+
} else {
|
|
1057
|
+
scrollEl.scrollTop = scrollEl.scrollHeight;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}, [chat.messages, chat.loadingHistory]);
|
|
703
1061
|
|
|
704
1062
|
const submit = React.useCallback(() => {
|
|
705
1063
|
const trimmed = value.trim();
|
|
@@ -739,7 +1097,12 @@ function AgentChat({
|
|
|
739
1097
|
// in — one that disappeared the instant the agent stopped talking would be no
|
|
740
1098
|
// use to anybody. The next run clears it.
|
|
741
1099
|
const showTools = toolEntries.length > 0 || chat.approvals.length > 0;
|
|
742
|
-
|
|
1100
|
+
|
|
1101
|
+
const Message = components?.Message;
|
|
1102
|
+
const Streaming = components?.Streaming;
|
|
1103
|
+
const LoadOlder = components?.LoadOlder ?? DefaultLoadOlder;
|
|
1104
|
+
const Tools = components?.Tools;
|
|
1105
|
+
const Composer = components?.Composer;
|
|
743
1106
|
|
|
744
1107
|
return (
|
|
745
1108
|
<div
|
|
@@ -747,41 +1110,44 @@ function AgentChat({
|
|
|
747
1110
|
className={cn("flex h-full min-h-0 w-full flex-col bg-[var(--ds-background)]", className)}
|
|
748
1111
|
{...props}
|
|
749
1112
|
>
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
size="sm"
|
|
758
|
-
variant="ghost"
|
|
759
|
-
data-slot="agent-chat-sessions-trigger"
|
|
760
|
-
aria-expanded={drawerOpen}
|
|
761
|
-
onClick={() => {
|
|
762
|
-
setDrawerOpen((open) => !open);
|
|
763
|
-
chat.refreshSessions();
|
|
764
|
-
}}
|
|
765
|
-
>
|
|
766
|
-
{chat.sessions.length === 1 ? "1 session" : `${chat.sessions.length} sessions`}
|
|
767
|
-
</Button>
|
|
768
|
-
<Button
|
|
769
|
-
size="sm"
|
|
770
|
-
variant="ghost"
|
|
771
|
-
data-slot="agent-chat-new-session"
|
|
772
|
-
aria-label="New session"
|
|
773
|
-
onClick={() => {
|
|
774
|
-
chat.newSession();
|
|
775
|
-
setDrawerOpen(false);
|
|
776
|
-
}}
|
|
1113
|
+
{hideHeader ? null : (
|
|
1114
|
+
<div
|
|
1115
|
+
data-slot="agent-chat-header"
|
|
1116
|
+
className={cn(
|
|
1117
|
+
"flex shrink-0 items-center gap-2 border-b border-[var(--ds-border-subtle)] px-3 py-2",
|
|
1118
|
+
classNames?.header,
|
|
1119
|
+
)}
|
|
777
1120
|
>
|
|
778
|
-
<
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1121
|
+
<Button
|
|
1122
|
+
size="sm"
|
|
1123
|
+
variant="ghost"
|
|
1124
|
+
data-slot="agent-chat-sessions-trigger"
|
|
1125
|
+
aria-expanded={drawerOpen}
|
|
1126
|
+
onClick={() => {
|
|
1127
|
+
setDrawerOpen((open) => !open);
|
|
1128
|
+
chat.refreshSessions();
|
|
1129
|
+
}}
|
|
1130
|
+
>
|
|
1131
|
+
{chat.sessions.length === 1 ? "1 session" : `${chat.sessions.length} sessions`}
|
|
1132
|
+
</Button>
|
|
1133
|
+
<Button
|
|
1134
|
+
size="sm"
|
|
1135
|
+
variant="ghost"
|
|
1136
|
+
data-slot="agent-chat-new-session"
|
|
1137
|
+
aria-label="New session"
|
|
1138
|
+
onClick={() => {
|
|
1139
|
+
chat.newSession();
|
|
1140
|
+
setDrawerOpen(false);
|
|
1141
|
+
}}
|
|
1142
|
+
>
|
|
1143
|
+
<MessageSquarePlus aria-hidden />
|
|
1144
|
+
New
|
|
1145
|
+
</Button>
|
|
1146
|
+
<div className="ml-auto flex items-center gap-1">{headerSlot}</div>
|
|
1147
|
+
</div>
|
|
1148
|
+
)}
|
|
783
1149
|
|
|
784
|
-
{drawerOpen ? (
|
|
1150
|
+
{drawerOpen && !hideHeader ? (
|
|
785
1151
|
<AgentSessionDrawer
|
|
786
1152
|
sessions={chat.sessions}
|
|
787
1153
|
current={chat.sessionKey}
|
|
@@ -793,76 +1159,125 @@ function AgentChat({
|
|
|
793
1159
|
/>
|
|
794
1160
|
) : null}
|
|
795
1161
|
|
|
796
|
-
|
|
797
|
-
ref={scrollRef}
|
|
798
|
-
data-slot="agent-chat-scroll"
|
|
799
|
-
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
|
800
|
-
>
|
|
1162
|
+
{showTranscript ? (
|
|
801
1163
|
<div
|
|
802
|
-
ref={
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1164
|
+
ref={scrollRef}
|
|
1165
|
+
data-slot="agent-chat-scroll"
|
|
1166
|
+
// `overflow-anchor: none`: the layout effect above owns the reader's
|
|
1167
|
+
// place across a prepend. Chrome anchors by itself and the two
|
|
1168
|
+
// together shoved the reader up by a page; Safari does not anchor at
|
|
1169
|
+
// all. One rule, ours, in every browser.
|
|
1170
|
+
className={cn(
|
|
1171
|
+
"min-h-0 flex-1 overflow-x-hidden overflow-y-auto [overflow-anchor:none]",
|
|
1172
|
+
classNames?.scroll,
|
|
1173
|
+
)}
|
|
807
1174
|
>
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1175
|
+
<div
|
|
1176
|
+
ref={contentRef}
|
|
1177
|
+
role="log"
|
|
1178
|
+
aria-live="polite"
|
|
1179
|
+
aria-label={`${agentName} chat`}
|
|
1180
|
+
className={cn(
|
|
1181
|
+
"relative flex min-w-0 flex-col",
|
|
1182
|
+
classNames?.transcript ?? "gap-5 px-4 py-4",
|
|
1183
|
+
)}
|
|
1184
|
+
>
|
|
1185
|
+
{/* The history sentinel: first, so reaching it means reaching the top. */}
|
|
1186
|
+
<div
|
|
1187
|
+
ref={sentinelRef}
|
|
1188
|
+
data-slot="agent-chat-sentinel"
|
|
1189
|
+
className={classNames?.sentinel ?? "absolute inset-x-0 top-0 h-px"}
|
|
1190
|
+
/>
|
|
811
1191
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
size="sm"
|
|
816
|
-
variant="ghost"
|
|
817
|
-
data-slot="agent-chat-load-older"
|
|
818
|
-
disabled={chat.loadingHistory}
|
|
819
|
-
onClick={chat.loadOlder}
|
|
820
|
-
>
|
|
821
|
-
{chat.loadingHistory ? "Loading…" : "Load older messages"}
|
|
822
|
-
</Button>
|
|
823
|
-
</div>
|
|
824
|
-
) : null}
|
|
1192
|
+
{chat.error ? (
|
|
1193
|
+
<ErrorBanner message={chat.error} />
|
|
1194
|
+
) : null}
|
|
825
1195
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
) : null}
|
|
1196
|
+
{chat.hasMoreHistory || chat.loadingHistory ? (
|
|
1197
|
+
<LoadOlder
|
|
1198
|
+
hasMore={chat.hasMoreHistory}
|
|
1199
|
+
loading={chat.loadingHistory}
|
|
1200
|
+
onLoad={loadOlder}
|
|
1201
|
+
/>
|
|
1202
|
+
) : null}
|
|
834
1203
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
))}
|
|
1204
|
+
{showEmpty ? (
|
|
1205
|
+
<p
|
|
1206
|
+
data-slot="agent-chat-empty"
|
|
1207
|
+
className="text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-muted-foreground)]"
|
|
1208
|
+
>
|
|
1209
|
+
Ask {agentName} anything about this screen.
|
|
1210
|
+
</p>
|
|
1211
|
+
) : null}
|
|
844
1212
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1213
|
+
{visible.map((message, index) =>
|
|
1214
|
+
Message ? (
|
|
1215
|
+
<Message
|
|
1216
|
+
key={message.id}
|
|
1217
|
+
message={message}
|
|
1218
|
+
index={index}
|
|
1219
|
+
messages={visible}
|
|
1220
|
+
agentName={agentName}
|
|
1221
|
+
sessionKey={chat.sessionKey}
|
|
1222
|
+
streaming={chat.streaming}
|
|
1223
|
+
onAction={chat.sendAction}
|
|
1224
|
+
/>
|
|
1225
|
+
) : (
|
|
1226
|
+
<AgentMessageBubble
|
|
1227
|
+
key={message.id}
|
|
1228
|
+
message={message}
|
|
1229
|
+
agentName={agentName}
|
|
1230
|
+
streaming={chat.streaming}
|
|
1231
|
+
onAction={chat.sendAction}
|
|
1232
|
+
/>
|
|
1233
|
+
),
|
|
1234
|
+
)}
|
|
1235
|
+
|
|
1236
|
+
{chat.streaming ? (
|
|
1237
|
+
Streaming ? (
|
|
1238
|
+
<Streaming
|
|
1239
|
+
agentName={agentName}
|
|
1240
|
+
text={chat.streamText}
|
|
1241
|
+
thinkingText={chat.streamThinkingText}
|
|
1242
|
+
a2ui={chat.streamA2ui}
|
|
1243
|
+
steps={chat.steps}
|
|
1244
|
+
/>
|
|
1245
|
+
) : (
|
|
1246
|
+
<AgentStreamingBubble
|
|
1247
|
+
agentName={agentName}
|
|
1248
|
+
text={chat.streamText}
|
|
1249
|
+
thinkingText={chat.streamThinkingText}
|
|
1250
|
+
a2ui={chat.streamA2ui}
|
|
1251
|
+
steps={chat.steps}
|
|
1252
|
+
/>
|
|
1253
|
+
)
|
|
1254
|
+
) : chat.steps.length > 0 ? (
|
|
1255
|
+
// The turn is over but its steps are the record of what was done to
|
|
1256
|
+
// answer it, and they stay until the next run replaces them — the
|
|
1257
|
+
// same rule the tool strip below follows.
|
|
1258
|
+
<AgentSteps steps={chat.steps} />
|
|
1259
|
+
) : null}
|
|
1260
|
+
</div>
|
|
859
1261
|
</div>
|
|
860
|
-
|
|
1262
|
+
) : Empty ? (
|
|
1263
|
+
<Empty agentName={agentName} loadingHistory={chat.loadingHistory} />
|
|
1264
|
+
) : null}
|
|
861
1265
|
|
|
862
|
-
{
|
|
1266
|
+
{Tools ? (
|
|
1267
|
+
<Tools
|
|
1268
|
+
entries={toolEntries}
|
|
1269
|
+
approvals={chat.approvals}
|
|
1270
|
+
streaming={chat.streaming}
|
|
1271
|
+
renderToolCall={renderToolCall}
|
|
1272
|
+
onResolveApproval={chat.resolveApproval}
|
|
1273
|
+
/>
|
|
1274
|
+
) : showTools ? (
|
|
863
1275
|
<div
|
|
864
1276
|
data-slot="agent-chat-tools"
|
|
865
|
-
className=
|
|
1277
|
+
className={cn(
|
|
1278
|
+
"flex shrink-0 flex-col gap-1.5 border-t border-[var(--ds-border-subtle)] px-3 py-2",
|
|
1279
|
+
classNames?.tools,
|
|
1280
|
+
)}
|
|
866
1281
|
>
|
|
867
1282
|
{toolEntries.length > 0 ? (
|
|
868
1283
|
<Badge variant="neutral" className="w-fit">
|
|
@@ -896,42 +1311,54 @@ function AgentChat({
|
|
|
896
1311
|
</div>
|
|
897
1312
|
) : null}
|
|
898
1313
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
aria-label={`Message ${agentName}`}
|
|
906
|
-
placeholder={`Message ${agentName}…`}
|
|
907
|
-
rows={1}
|
|
908
|
-
className="max-h-40 min-h-9 resize-none"
|
|
909
|
-
value={value}
|
|
910
|
-
onChange={(event) => setValue(event.target.value)}
|
|
911
|
-
onKeyDown={onKeyDown}
|
|
1314
|
+
{Composer ? (
|
|
1315
|
+
<Composer
|
|
1316
|
+
agentName={agentName}
|
|
1317
|
+
streaming={chat.streaming}
|
|
1318
|
+
send={chat.send}
|
|
1319
|
+
abort={chat.abort}
|
|
912
1320
|
/>
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1321
|
+
) : (
|
|
1322
|
+
<div
|
|
1323
|
+
data-slot="agent-chat-composer"
|
|
1324
|
+
className={cn(
|
|
1325
|
+
"flex shrink-0 items-end gap-2 border-t border-[var(--ds-border-subtle)] p-3",
|
|
1326
|
+
classNames?.composer,
|
|
1327
|
+
)}
|
|
1328
|
+
>
|
|
1329
|
+
<Textarea
|
|
1330
|
+
data-slot="agent-chat-input"
|
|
1331
|
+
aria-label={`Message ${agentName}`}
|
|
1332
|
+
placeholder={`Message ${agentName}…`}
|
|
1333
|
+
rows={1}
|
|
1334
|
+
className="max-h-40 min-h-9 resize-none"
|
|
1335
|
+
value={value}
|
|
1336
|
+
onChange={(event) => setValue(event.target.value)}
|
|
1337
|
+
onKeyDown={onKeyDown}
|
|
1338
|
+
/>
|
|
1339
|
+
{chat.streaming ? (
|
|
1340
|
+
<Button
|
|
1341
|
+
size="icon"
|
|
1342
|
+
variant="outline"
|
|
1343
|
+
data-slot="agent-chat-stop"
|
|
1344
|
+
aria-label="Stop the run"
|
|
1345
|
+
onClick={chat.abort}
|
|
1346
|
+
>
|
|
1347
|
+
<Square aria-hidden />
|
|
1348
|
+
</Button>
|
|
1349
|
+
) : (
|
|
1350
|
+
<Button
|
|
1351
|
+
size="icon"
|
|
1352
|
+
data-slot="agent-chat-send"
|
|
1353
|
+
aria-label="Send"
|
|
1354
|
+
disabled={!value.trim()}
|
|
1355
|
+
onClick={submit}
|
|
1356
|
+
>
|
|
1357
|
+
<Send aria-hidden />
|
|
1358
|
+
</Button>
|
|
1359
|
+
)}
|
|
1360
|
+
</div>
|
|
1361
|
+
)}
|
|
935
1362
|
</div>
|
|
936
1363
|
);
|
|
937
1364
|
}
|