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.
@@ -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
- export interface AgentChatProps extends Omit<React.ComponentProps<"div">, "children" | "onError"> {
624
- /** The transport. `createAguiAgentChatClient` is the AG-UI implementation. */
625
- client: AgentChatClient;
626
- /** The extension name, shown on each assistant turn. */
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
- * Names the `sessionStorage` slot this window's session key lives in. Scope
630
- * it to the extension id; two chats with different scopes never share a key.
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
- storageScope: string;
633
- /** Do not read or write `sessionStorage`; for a guide or a test. */
634
- ephemeral?: boolean;
635
- thinking?: "low" | "medium" | "high";
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
- function AgentChat({
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
- attachmentsSlot,
655
- renderToolCall,
656
- headerSlot,
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
- const visible = chat.messages.filter((message) => !isHiddenMessage(message));
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
- <div
751
- data-slot="agent-chat-header"
752
- className={cn(
753
- "flex shrink-0 items-center gap-2 border-b border-[var(--ds-border-subtle)] px-3 py-2",
754
- )}
755
- >
756
- <Button
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
- <MessageSquarePlus aria-hidden />
779
- New
780
- </Button>
781
- <div className="ml-auto flex items-center gap-1">{headerSlot}</div>
782
- </div>
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
- <div
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={contentRef}
803
- role="log"
804
- aria-live="polite"
805
- aria-label={`${agentName} chat`}
806
- className="flex min-w-0 flex-col gap-5 px-4 py-4"
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
- {chat.error ? (
809
- <ErrorBanner message={chat.error} />
810
- ) : null}
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
- {chat.hasMoreHistory ? (
813
- <div className="flex justify-center">
814
- <Button
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
- {visible.length === 0 && !chat.streaming ? (
827
- <p
828
- data-slot="agent-chat-empty"
829
- className="text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-muted-foreground)]"
830
- >
831
- Ask {agentName} anything about this screen.
832
- </p>
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
- {visible.map((message) => (
836
- <AgentMessageBubble
837
- key={message.id}
838
- message={message}
839
- agentName={agentName}
840
- streaming={chat.streaming}
841
- onAction={chat.sendAction}
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
- {chat.streaming ? (
846
- <AgentStreamingBubble
847
- agentName={agentName}
848
- text={chat.streamText}
849
- thinkingText={chat.streamThinkingText}
850
- a2ui={chat.streamA2ui}
851
- steps={chat.steps}
852
- />
853
- ) : chat.steps.length > 0 ? (
854
- // The turn is over but its steps are the record of what was done to
855
- // answer it, and they stay until the next run replaces them — the
856
- // same rule the tool strip below follows.
857
- <AgentSteps steps={chat.steps} />
858
- ) : null}
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
- </div>
1262
+ ) : Empty ? (
1263
+ <Empty agentName={agentName} loadingHistory={chat.loadingHistory} />
1264
+ ) : null}
861
1265
 
862
- {showTools ? (
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="flex shrink-0 flex-col gap-1.5 border-t border-[var(--ds-border-subtle)] px-3 py-2"
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
- <div
900
- data-slot="agent-chat-composer"
901
- className="flex shrink-0 items-end gap-2 border-t border-[var(--ds-border-subtle)] p-3"
902
- >
903
- <Textarea
904
- data-slot="agent-chat-input"
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
- {chat.streaming ? (
914
- <Button
915
- size="icon"
916
- variant="outline"
917
- data-slot="agent-chat-stop"
918
- aria-label="Stop the run"
919
- onClick={chat.abort}
920
- >
921
- <Square aria-hidden />
922
- </Button>
923
- ) : (
924
- <Button
925
- size="icon"
926
- data-slot="agent-chat-send"
927
- aria-label="Send"
928
- disabled={!value.trim()}
929
- onClick={submit}
930
- >
931
- <Send aria-hidden />
932
- </Button>
933
- )}
934
- </div>
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
  }