@artooi/ag-ui-web-component 0.27.0 → 0.29.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 +663 -1
- package/README.md +557 -11
- package/dist/ag-ui-web-component.bundle.js +294 -36
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +69 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +262 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +46 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/conversation_store.d.ts +43 -1
- package/dist/core/conversation_store.d.ts.map +1 -1
- package/dist/core/create_http_agent.d.ts +13 -0
- package/dist/core/create_http_agent.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts +23 -1
- package/dist/core/remote_conversation_store.d.ts.map +1 -1
- package/dist/core/utils.d.ts +28 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2104 -194
- package/dist/index.js.map +4 -4
- package/dist/tools/is_destructive.d.ts +8 -2
- package/dist/tools/is_destructive.d.ts.map +1 -1
- package/dist/tools/parse_tool_catalog.d.ts +11 -4
- package/dist/tools/parse_tool_catalog.d.ts.map +1 -1
- package/dist/ui/approval_card.d.ts +18 -0
- package/dist/ui/approval_card.d.ts.map +1 -1
- package/dist/ui/checkpoint_menu.d.ts +10 -0
- package/dist/ui/checkpoint_menu.d.ts.map +1 -1
- package/dist/ui/confirmation_card.d.ts +16 -0
- package/dist/ui/confirmation_card.d.ts.map +1 -1
- package/dist/ui/message_actions.d.ts +46 -0
- package/dist/ui/message_actions.d.ts.map +1 -0
- package/dist/ui/page_quote_offer.d.ts +33 -0
- package/dist/ui/page_quote_offer.d.ts.map +1 -0
- package/dist/ui/quote_selection.d.ts +66 -0
- package/dist/ui/quote_selection.d.ts.map +1 -0
- package/dist/ui/relative_time.d.ts +10 -0
- package/dist/ui/relative_time.d.ts.map +1 -1
- package/dist/ui/render_markdown.d.ts +23 -5
- package/dist/ui/render_markdown.d.ts.map +1 -1
- package/dist/ui/resize_handle.d.ts +5 -1
- package/dist/ui/resize_handle.d.ts.map +1 -1
- package/dist/ui/stick_to_bottom.d.ts +55 -0
- package/dist/ui/stick_to_bottom.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/suggestion_chips.d.ts +29 -0
- package/dist/ui/suggestion_chips.d.ts.map +1 -0
- package/dist/ui/thread_drawer.d.ts +10 -0
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/tool_call_card.d.ts +8 -0
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +53 -7
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/dist/ui/voice_input.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +75 -0
- package/src/core/ag_ui_chat.ts +1357 -113
- package/src/core/agui_client.ts +81 -1
- package/src/core/conversation_store.ts +128 -42
- package/src/core/create_http_agent.ts +24 -2
- package/src/core/remote_conversation_store.ts +35 -2
- package/src/core/utils.ts +58 -0
- package/src/index.ts +39 -0
- package/src/tools/is_destructive.ts +8 -2
- package/src/tools/parse_tool_catalog.ts +18 -6
- package/src/ui/approval_card.ts +90 -2
- package/src/ui/checkpoint_menu.ts +22 -5
- package/src/ui/confirmation_card.ts +29 -1
- package/src/ui/message_actions.ts +158 -0
- package/src/ui/page_quote_offer.ts +215 -0
- package/src/ui/quote_selection.ts +345 -0
- package/src/ui/relative_time.ts +11 -0
- package/src/ui/render_markdown.ts +111 -21
- package/src/ui/resize_handle.ts +32 -2
- package/src/ui/stick_to_bottom.ts +126 -0
- package/src/ui/styles.ts +227 -0
- package/src/ui/suggestion_chips.ts +73 -0
- package/src/ui/thread_drawer.ts +22 -2
- package/src/ui/tool_call_card.ts +9 -0
- package/src/ui/ui_strings.ts +79 -8
- package/src/ui/voice_input.ts +43 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** A transcript that follows new content, unless the reader has other ideas. */
|
|
2
|
+
export interface StickToBottom {
|
|
3
|
+
/**
|
|
4
|
+
* New content arrived. Scrolls to the bottom only while following, so
|
|
5
|
+
* reading older messages during a run is no longer undone on the next token.
|
|
6
|
+
*/
|
|
7
|
+
readonly follow: () => void;
|
|
8
|
+
/** Go to the bottom and resume following, whatever the reader was doing. */
|
|
9
|
+
readonly jump: () => void;
|
|
10
|
+
/** Whether the transcript is currently following new content. */
|
|
11
|
+
readonly following: () => boolean;
|
|
12
|
+
readonly dispose: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface StickToBottomOptions {
|
|
16
|
+
/** The scrolling element -- the message list. */
|
|
17
|
+
readonly viewport: HTMLElement;
|
|
18
|
+
/**
|
|
19
|
+
* Called whenever the answer to "should a jump-to-latest affordance show?"
|
|
20
|
+
* changes. True means the reader has scrolled away *and* has since missed
|
|
21
|
+
* something; scrolling up through a settled transcript is not a reason to
|
|
22
|
+
* nag.
|
|
23
|
+
*/
|
|
24
|
+
readonly onMissedContent: (missed: boolean) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How close to the bottom still counts as the bottom, in CSS pixels.
|
|
29
|
+
*
|
|
30
|
+
* Not zero: `scrollHeight - scrollTop - clientHeight` lands on fractional
|
|
31
|
+
* values under a zoom level or a fractional device pixel ratio, so an exact
|
|
32
|
+
* comparison reports "scrolled away" for a transcript that is visibly pinned.
|
|
33
|
+
*/
|
|
34
|
+
const BOTTOM_SLACK_PX = 4;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Follow the foot of a scrolling transcript, and stop when the reader scrolls
|
|
38
|
+
* away.
|
|
39
|
+
*
|
|
40
|
+
* Before this, eleven separate sites assigned `scrollTop = scrollHeight`
|
|
41
|
+
* unconditionally and nothing anywhere listened for a `scroll` event -- so
|
|
42
|
+
* nothing knew the reader had scrolled up, and scrolling back through a run was
|
|
43
|
+
* undone by the next token. Stick-to-bottom with a jump-to-latest affordance is
|
|
44
|
+
* a named primitive elsewhere for exactly this reason: shadcn ships it as
|
|
45
|
+
* `MessageScroller`, AI Elements as `ConversationScrollButton`.
|
|
46
|
+
*
|
|
47
|
+
* **Telling a reader's scroll from our own is the whole problem**, and the
|
|
48
|
+
* answer here is that it does not have to be told. A programmatic scroll only
|
|
49
|
+
* ever happens while already following, and it lands at the bottom, so the
|
|
50
|
+
* `scroll` event it provokes recomputes "at the bottom" as true and changes
|
|
51
|
+
* nothing. A reader's scroll is the only kind that can move the answer.
|
|
52
|
+
*
|
|
53
|
+
* A `ResizeObserver` covers the case scroll events cannot see: the *viewport*
|
|
54
|
+
* changing size. Resizing the panel, or the keyboard opening on a phone, moves
|
|
55
|
+
* the foot without anything scrolling and without any content arriving, so a
|
|
56
|
+
* pinned transcript would silently come unpinned.
|
|
57
|
+
*
|
|
58
|
+
* ⚠ It does **not** cover content that grows after insertion -- an image
|
|
59
|
+
* decoding, a chart laying out. A `ResizeObserver` on a scroll container does
|
|
60
|
+
* not fire when its `scrollHeight` changes, so catching that means observing
|
|
61
|
+
* every child, and the payoff is one late nudge in a case the reader can fix by
|
|
62
|
+
* scrolling. Insertion itself is covered: every site that adds to the
|
|
63
|
+
* transcript calls {@link StickToBottom.follow}.
|
|
64
|
+
*/
|
|
65
|
+
export function createStickToBottom({
|
|
66
|
+
viewport,
|
|
67
|
+
onMissedContent,
|
|
68
|
+
}: StickToBottomOptions): StickToBottom {
|
|
69
|
+
let isFollowing = true;
|
|
70
|
+
let missed = false;
|
|
71
|
+
|
|
72
|
+
const atBottom = (): boolean =>
|
|
73
|
+
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= BOTTOM_SLACK_PX;
|
|
74
|
+
|
|
75
|
+
const setMissed = (next: boolean): void => {
|
|
76
|
+
if (next === missed) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
missed = next;
|
|
80
|
+
onMissedContent(missed);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const toBottom = (): void => {
|
|
84
|
+
viewport.scrollTop = viewport.scrollHeight;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const onScroll = (): void => {
|
|
88
|
+
isFollowing = atBottom();
|
|
89
|
+
if (isFollowing) {
|
|
90
|
+
setMissed(false);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const follow = (): void => {
|
|
95
|
+
if (isFollowing) {
|
|
96
|
+
toBottom();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setMissed(true);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Passive: this listener never calls preventDefault, and saying so keeps it
|
|
103
|
+
// off the critical path of a scroll it has no intention of blocking.
|
|
104
|
+
viewport.addEventListener("scroll", onScroll, { passive: true });
|
|
105
|
+
|
|
106
|
+
const observer = new ResizeObserver(() => {
|
|
107
|
+
if (isFollowing) {
|
|
108
|
+
toBottom();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
observer.observe(viewport);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
follow,
|
|
115
|
+
jump: (): void => {
|
|
116
|
+
isFollowing = true;
|
|
117
|
+
setMissed(false);
|
|
118
|
+
toBottom();
|
|
119
|
+
},
|
|
120
|
+
following: (): boolean => isFollowing,
|
|
121
|
+
dispose: (): void => {
|
|
122
|
+
viewport.removeEventListener("scroll", onScroll);
|
|
123
|
+
observer.disconnect();
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
package/src/ui/styles.ts
CHANGED
|
@@ -571,6 +571,7 @@ export const STYLES = `
|
|
|
571
571
|
opacity: 0;
|
|
572
572
|
}
|
|
573
573
|
|
|
574
|
+
:host([collapsed]:is([placement="embedded"], [placement="page"])) .messages-wrap,
|
|
574
575
|
:host([collapsed]:is([placement="embedded"], [placement="page"])) .messages,
|
|
575
576
|
:host([collapsed]:is([placement="embedded"], [placement="page"])) .input-row,
|
|
576
577
|
:host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-chips,
|
|
@@ -579,9 +580,113 @@ export const STYLES = `
|
|
|
579
580
|
display: none;
|
|
580
581
|
}
|
|
581
582
|
|
|
583
|
+
/* Jump-to-latest: shown only once the reader has scrolled away *and* missed
|
|
584
|
+
something. Anchored to the panel rather than the list so it does not scroll
|
|
585
|
+
with the content it is offering to scroll to. */
|
|
586
|
+
/* The transcript's own box, and the only one whose foot is the transcript's
|
|
587
|
+
foot. The panel's foot is below the composer, the chips and the footer. */
|
|
588
|
+
.messages-wrap {
|
|
589
|
+
position: relative;
|
|
590
|
+
flex: 1;
|
|
591
|
+
min-height: 0;
|
|
592
|
+
display: flex;
|
|
593
|
+
flex-direction: column;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
.jump-latest {
|
|
597
|
+
position: absolute;
|
|
598
|
+
left: 50%;
|
|
599
|
+
transform: translateX(-50%);
|
|
600
|
+
bottom: var(--_pad);
|
|
601
|
+
z-index: 2;
|
|
602
|
+
display: none;
|
|
603
|
+
align-items: center;
|
|
604
|
+
gap: 0.35em;
|
|
605
|
+
padding: 0.4em 0.9em;
|
|
606
|
+
border: 1px solid var(--_border);
|
|
607
|
+
border-radius: 999px;
|
|
608
|
+
/* A raised surface, not the panel's own background. Reusing --_bg made the
|
|
609
|
+
pill the same colour as everything behind it, leaving a 1px border and a
|
|
610
|
+
shadow to carry the whole affordance -- and a dark-on-dark shadow carries
|
|
611
|
+
nothing. --_hover is the token that already means "lifted off the panel",
|
|
612
|
+
and it separates in both themes without competing with the accent the send
|
|
613
|
+
button owns. */
|
|
614
|
+
background: var(--_hover);
|
|
615
|
+
color: var(--_text);
|
|
616
|
+
font: inherit;
|
|
617
|
+
font-size: 0.85em;
|
|
618
|
+
cursor: pointer;
|
|
619
|
+
box-shadow: 0 2px 10px rgb(0 0 0 / 0.18);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
.jump-latest[data-missed="true"] {
|
|
623
|
+
display: flex;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.jump-latest:hover {
|
|
627
|
+
border-color: var(--_accent);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/* The offer to quote a selection. Positioned in script against the transcript
|
|
631
|
+
box, which is the only ancestor whose top and foot are the transcript's --
|
|
632
|
+
the same reason .jump-latest lives here. The translate is the half the
|
|
633
|
+
script does not do: script sets the point the offer hangs from, CSS decides
|
|
634
|
+
which corner of the offer that point is. */
|
|
635
|
+
.quote-selection {
|
|
636
|
+
position: absolute;
|
|
637
|
+
z-index: 2;
|
|
638
|
+
transform: translate(-50%, -100%);
|
|
639
|
+
padding: 0.25em 0.7em;
|
|
640
|
+
border: 1px solid var(--_border);
|
|
641
|
+
border-radius: 999px;
|
|
642
|
+
background: var(--_hover);
|
|
643
|
+
color: var(--_text);
|
|
644
|
+
font: inherit;
|
|
645
|
+
font-size: 0.8em;
|
|
646
|
+
line-height: 1.6;
|
|
647
|
+
white-space: nowrap;
|
|
648
|
+
cursor: pointer;
|
|
649
|
+
box-shadow: 0 2px 10px rgb(0 0 0 / 0.18);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/* Flipped under the selection when there was no room above it. Only the
|
|
653
|
+
vertical half of the translate changes: it still hangs from its own centre
|
|
654
|
+
horizontally. */
|
|
655
|
+
.quote-selection[data-below="true"] {
|
|
656
|
+
transform: translate(-50%, 0);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.quote-selection:hover {
|
|
660
|
+
border-color: var(--_accent);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/* Screen-reader-only status region. Off-screen rather than display:none or
|
|
664
|
+
visibility:hidden, both of which take the element out of the accessibility
|
|
665
|
+
tree entirely -- a hidden live region announces nothing at all, which is the
|
|
666
|
+
classic way this pattern is written wrong.
|
|
667
|
+
|
|
668
|
+
The 1px box with clip-path, rather than width/height 0, is the shape that
|
|
669
|
+
survives: a zero-sized element is dropped from the tree by some engines. */
|
|
670
|
+
.sr-only {
|
|
671
|
+
position: absolute;
|
|
672
|
+
width: 1px;
|
|
673
|
+
height: 1px;
|
|
674
|
+
margin: -1px;
|
|
675
|
+
padding: 0;
|
|
676
|
+
border: 0;
|
|
677
|
+
overflow: hidden;
|
|
678
|
+
white-space: nowrap;
|
|
679
|
+
clip-path: inset(50%);
|
|
680
|
+
}
|
|
681
|
+
|
|
582
682
|
.messages {
|
|
583
683
|
flex: 1;
|
|
584
684
|
overflow-y: auto;
|
|
685
|
+
/* The browser's own scroll anchoring competes with the scroller for the same
|
|
686
|
+
job and wins unpredictably -- it can hold the view still exactly when we
|
|
687
|
+
want to follow. Turned off so following is decided in one place. Safari
|
|
688
|
+
does not implement it, which is itself a reason not to depend on it. */
|
|
689
|
+
overflow-anchor: none;
|
|
585
690
|
padding: var(--_pad);
|
|
586
691
|
display: flex;
|
|
587
692
|
flex-direction: column;
|
|
@@ -1747,6 +1852,7 @@ export const STYLES = `
|
|
|
1747
1852
|
|
|
1748
1853
|
.confirm-actions {
|
|
1749
1854
|
display: flex;
|
|
1855
|
+
flex-wrap: wrap;
|
|
1750
1856
|
gap: 8px;
|
|
1751
1857
|
justify-content: flex-end;
|
|
1752
1858
|
}
|
|
@@ -1773,6 +1879,99 @@ export const STYLES = `
|
|
|
1773
1879
|
color: #ffffff;
|
|
1774
1880
|
}
|
|
1775
1881
|
|
|
1882
|
+
/* The session waiver. Deliberately the quietest of the three: it is the widest
|
|
1883
|
+
decision on the card, so it should be reachable without being the one the eye
|
|
1884
|
+
lands on when the user means to say yes once. */
|
|
1885
|
+
.confirm-btn--always {
|
|
1886
|
+
font-weight: 500;
|
|
1887
|
+
opacity: 0.85;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
.confirm-btn--always:hover,
|
|
1891
|
+
.confirm-btn--always:focus-visible {
|
|
1892
|
+
opacity: 1;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
/* Editable arguments on an approval card. A plain field rather than a code
|
|
1896
|
+
editor: it holds the JSON a card already displays, and the only interaction
|
|
1897
|
+
is correcting a value before letting the call run. */
|
|
1898
|
+
.approval-edit {
|
|
1899
|
+
display: flex;
|
|
1900
|
+
flex-direction: column;
|
|
1901
|
+
gap: 6px;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
.approval-args {
|
|
1905
|
+
box-sizing: border-box;
|
|
1906
|
+
width: 100%;
|
|
1907
|
+
resize: vertical;
|
|
1908
|
+
border: 1px solid var(--_border);
|
|
1909
|
+
border-radius: 8px;
|
|
1910
|
+
padding: 8px;
|
|
1911
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1912
|
+
font-size: 0.85em;
|
|
1913
|
+
background: var(--_bg);
|
|
1914
|
+
color: var(--_fg);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
.approval-args:focus-visible {
|
|
1918
|
+
border-color: var(--_accent);
|
|
1919
|
+
outline: none;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
.approval-error {
|
|
1923
|
+
font-size: 0.85em;
|
|
1924
|
+
color: var(--_danger);
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/* Message action row. Sits under a finished assistant bubble.
|
|
1928
|
+
|
|
1929
|
+
The wrap is insurance rather than a fix: these buttons are glyph-only, so at
|
|
1930
|
+
every width tested they fit on one line and removing the wrap changes
|
|
1931
|
+
nothing. It is here because the confirmation row one release earlier did
|
|
1932
|
+
overflow when it gained a third button, off the left edge and outside its own
|
|
1933
|
+
card, and the difference between the two rows is only that this one's labels
|
|
1934
|
+
are icons today. */
|
|
1935
|
+
.message-actions {
|
|
1936
|
+
display: flex;
|
|
1937
|
+
flex-wrap: wrap;
|
|
1938
|
+
gap: 4px;
|
|
1939
|
+
/* Negative, and that is the point. The answer group is a flex column with its
|
|
1940
|
+
own gap, so a positive margin here pushes the row further from the message
|
|
1941
|
+
it acts on than the next card is below it -- the buttons then read as
|
|
1942
|
+
belonging to whatever follows. Pulling back inside the gap is what makes
|
|
1943
|
+
them the message's own. */
|
|
1944
|
+
margin-top: -6px;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
.message-action {
|
|
1948
|
+
border: none;
|
|
1949
|
+
border-radius: 6px;
|
|
1950
|
+
padding: 2px 6px;
|
|
1951
|
+
font: inherit;
|
|
1952
|
+
line-height: 1.2;
|
|
1953
|
+
cursor: pointer;
|
|
1954
|
+
background: transparent;
|
|
1955
|
+
color: var(--_muted);
|
|
1956
|
+
opacity: 0.75;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
.message-action:hover,
|
|
1960
|
+
.message-action:focus-visible {
|
|
1961
|
+
opacity: 1;
|
|
1962
|
+
background: var(--_border);
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
.message-action[aria-pressed="true"] {
|
|
1966
|
+
opacity: 1;
|
|
1967
|
+
color: var(--_accent);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
.message-action--confirmed {
|
|
1971
|
+
opacity: 1;
|
|
1972
|
+
color: var(--_accent);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1776
1975
|
/* Approval card — the server-side-tool gate (approve/deny an interrupt). */
|
|
1777
1976
|
.approval {
|
|
1778
1977
|
align-self: stretch;
|
|
@@ -1917,6 +2116,34 @@ export const STYLES = `
|
|
|
1917
2116
|
border-color: var(--_accent);
|
|
1918
2117
|
}
|
|
1919
2118
|
|
|
2119
|
+
/* Follow-up suggestion chips. Deliberately the skill chips' shape rather than a
|
|
2120
|
+
second chip vocabulary -- both are "a question you could ask", and the only
|
|
2121
|
+
difference is who chose it. Inside the transcript, so they scroll with the
|
|
2122
|
+
answer they follow instead of hovering above the composer. */
|
|
2123
|
+
.suggestions {
|
|
2124
|
+
display: flex;
|
|
2125
|
+
flex-wrap: wrap;
|
|
2126
|
+
gap: 6px;
|
|
2127
|
+
align-self: stretch;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
.suggestion-chip {
|
|
2131
|
+
border: 1px solid var(--_border);
|
|
2132
|
+
border-radius: 999px;
|
|
2133
|
+
padding: 4px 12px;
|
|
2134
|
+
font: inherit;
|
|
2135
|
+
font-size: 0.9em;
|
|
2136
|
+
text-align: left;
|
|
2137
|
+
cursor: pointer;
|
|
2138
|
+
background: var(--_assistant-bg);
|
|
2139
|
+
color: var(--_fg);
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
.suggestion-chip:hover,
|
|
2143
|
+
.suggestion-chip:focus-visible {
|
|
2144
|
+
border-color: var(--_accent);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
1920
2147
|
.skill-palette {
|
|
1921
2148
|
margin: 8px 12px 0;
|
|
1922
2149
|
display: flex;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { UiStrings } from "./ui_strings.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Most prompts one push may draw.
|
|
5
|
+
*
|
|
6
|
+
* **Keep in step with `MAX_SUGGESTIONS` in django-ag-ui's
|
|
7
|
+
* `agent/suggestions_activity.py`**, which raises past it. Mirroring is the
|
|
8
|
+
* whole point: this side silently draws no more than its limit and has no
|
|
9
|
+
* channel to report the difference, so a producer that does not know the same
|
|
10
|
+
* number ships suggestions that never appear. That is the hole the chart bounds
|
|
11
|
+
* exist to close, and it was found there by shipping it.
|
|
12
|
+
*/
|
|
13
|
+
export const MAX_SUGGESTIONS = 4;
|
|
14
|
+
|
|
15
|
+
/** Longest one prompt may be. Mirrored for the same reason as the count. */
|
|
16
|
+
export const MAX_SUGGESTION_CHARS = 120;
|
|
17
|
+
|
|
18
|
+
/** The `prompts` a `suggestions` activity carries, or `null` when it carries none. */
|
|
19
|
+
export function suggestionPrompts(content: unknown): string[] | null {
|
|
20
|
+
if (typeof content !== "object" || content === null) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const raw = (content as { prompts?: unknown }).prompts;
|
|
24
|
+
if (!Array.isArray(raw)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const prompts = raw
|
|
28
|
+
.filter((prompt): prompt is string => typeof prompt === "string")
|
|
29
|
+
.map((prompt) => prompt.trim())
|
|
30
|
+
.filter((prompt) => prompt !== "" && prompt.length <= MAX_SUGGESTION_CHARS)
|
|
31
|
+
.slice(0, MAX_SUGGESTIONS);
|
|
32
|
+
return prompts.length === 0 ? null : prompts;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Draw follow-up prompts as chips that send themselves when clicked.
|
|
37
|
+
*
|
|
38
|
+
* Returns `null` when nothing survives, which is the registry's signal to draw
|
|
39
|
+
* nothing rather than an empty row -- the same contract the chart renderer uses
|
|
40
|
+
* for a spec it cannot draw.
|
|
41
|
+
*
|
|
42
|
+
* Buttons rather than links or list items: each one performs an action in the
|
|
43
|
+
* page, and the thing it sends is the label, so the accessible name is the
|
|
44
|
+
* prompt itself and needs no `aria-label` restating it.
|
|
45
|
+
*/
|
|
46
|
+
export function renderSuggestionChips(
|
|
47
|
+
content: unknown,
|
|
48
|
+
strings: UiStrings,
|
|
49
|
+
onPick: (prompt: string) => void,
|
|
50
|
+
): HTMLElement | null {
|
|
51
|
+
const prompts = suggestionPrompts(content);
|
|
52
|
+
if (prompts === null) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const row = document.createElement("div");
|
|
56
|
+
row.className = "suggestions";
|
|
57
|
+
row.setAttribute("part", "suggestions");
|
|
58
|
+
// A group, labelled: without it a screen reader meets a row of unrelated
|
|
59
|
+
// buttons with no hint that they are the assistant's offer rather than the
|
|
60
|
+
// page's own controls.
|
|
61
|
+
row.setAttribute("role", "group");
|
|
62
|
+
row.setAttribute("aria-label", strings.suggestions);
|
|
63
|
+
for (const prompt of prompts) {
|
|
64
|
+
const chip = document.createElement("button");
|
|
65
|
+
chip.type = "button";
|
|
66
|
+
chip.className = "suggestion-chip";
|
|
67
|
+
chip.setAttribute("part", "suggestion-chip");
|
|
68
|
+
chip.textContent = prompt;
|
|
69
|
+
chip.addEventListener("click", () => onPick(prompt));
|
|
70
|
+
row.appendChild(chip);
|
|
71
|
+
}
|
|
72
|
+
return row;
|
|
73
|
+
}
|
package/src/ui/thread_drawer.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ThreadMeta } from "../core/conversation_store.js";
|
|
2
|
-
import { relativeTime } from "./relative_time.js";
|
|
2
|
+
import { type RelativeTimeFormatter, relativeTime } from "./relative_time.js";
|
|
3
3
|
import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
|
|
4
4
|
|
|
5
5
|
/** Actions the host ({@link AgUiChat}) wires to the drawer's rows. */
|
|
@@ -35,6 +35,7 @@ export class ThreadDrawer {
|
|
|
35
35
|
readonly #heading: HTMLSpanElement;
|
|
36
36
|
readonly #newButton: HTMLButtonElement;
|
|
37
37
|
readonly #list: HTMLDivElement;
|
|
38
|
+
#formatRelativeTime: RelativeTimeFormatter | null = null;
|
|
38
39
|
#strings: UiStrings;
|
|
39
40
|
#threads: readonly ThreadMeta[] = [];
|
|
40
41
|
#activeId = "";
|
|
@@ -91,6 +92,25 @@ export class ThreadDrawer {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
/** Re-localize the drawer's chrome and rows (the host calls this on connect). */
|
|
95
|
+
/**
|
|
96
|
+
* Replace the timestamp formatter, or restore the built-in with `null`.
|
|
97
|
+
*
|
|
98
|
+
* The built-in is deliberately locale-neutral -- there is no `Intl` anywhere
|
|
99
|
+
* in this component, so it never disagrees with a host's own formatting by
|
|
100
|
+
* guessing a locale. That is a defensible default and a poor requirement, so
|
|
101
|
+
* this is the way out.
|
|
102
|
+
*/
|
|
103
|
+
setRelativeTimeFormatter(format: RelativeTimeFormatter | null): void {
|
|
104
|
+
this.#formatRelativeTime = format;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** This row's timestamp, through the host's formatter when it set one. */
|
|
108
|
+
#formatTime(timestamp: number): string {
|
|
109
|
+
return this.#formatRelativeTime !== null
|
|
110
|
+
? this.#formatRelativeTime(timestamp)
|
|
111
|
+
: relativeTime(timestamp, undefined, this.#strings);
|
|
112
|
+
}
|
|
113
|
+
|
|
94
114
|
setStrings(strings: UiStrings): void {
|
|
95
115
|
this.#strings = strings;
|
|
96
116
|
this.#panel.setAttribute("aria-label", strings.chatHistory);
|
|
@@ -202,7 +222,7 @@ export class ThreadDrawer {
|
|
|
202
222
|
const time = document.createElement("span");
|
|
203
223
|
time.className = "drawer-row-time";
|
|
204
224
|
time.setAttribute("part", "drawer-row-time");
|
|
205
|
-
time.textContent =
|
|
225
|
+
time.textContent = this.#formatTime(meta.updatedAt);
|
|
206
226
|
const preview = document.createElement("span");
|
|
207
227
|
preview.className = "drawer-row-preview";
|
|
208
228
|
preview.setAttribute("part", "drawer-row-preview");
|
package/src/ui/tool_call_card.ts
CHANGED
|
@@ -90,6 +90,14 @@ export class ToolCallCard {
|
|
|
90
90
|
readonly #resultLabel: HTMLSpanElement;
|
|
91
91
|
readonly #resultBody: HTMLPreElement;
|
|
92
92
|
readonly #strings: UiStrings;
|
|
93
|
+
/**
|
|
94
|
+
* The arguments this call was made with.
|
|
95
|
+
*
|
|
96
|
+
* Retained rather than only rendered, because an approval interrupt names a
|
|
97
|
+
* `toolCallId` and nothing else -- so this card is the only place the args
|
|
98
|
+
* still exist when the user is asked to approve, edit or deny the call.
|
|
99
|
+
*/
|
|
100
|
+
readonly args: Record<string, unknown>;
|
|
93
101
|
#settled = false;
|
|
94
102
|
|
|
95
103
|
constructor(
|
|
@@ -99,6 +107,7 @@ export class ToolCallCard {
|
|
|
99
107
|
strings: UiStrings = DEFAULT_UI_STRINGS,
|
|
100
108
|
) {
|
|
101
109
|
this.#strings = strings;
|
|
110
|
+
this.args = args;
|
|
102
111
|
|
|
103
112
|
this.element = document.createElement("div");
|
|
104
113
|
this.element.className = "tool-call";
|
package/src/ui/ui_strings.ts
CHANGED
|
@@ -33,6 +33,18 @@ export interface UiStrings {
|
|
|
33
33
|
// ── Messages region ─────────────────────────────────────────────────────────
|
|
34
34
|
/** `aria-label` of the scrolling message log. */
|
|
35
35
|
conversation: string;
|
|
36
|
+
/** The button offering to return to the foot of the transcript. */
|
|
37
|
+
jumpToLatest: string;
|
|
38
|
+
/** Announced when a turn starts. Screen-reader only; never rendered. */
|
|
39
|
+
announceResponding: string;
|
|
40
|
+
/** Announced when the answer has finished arriving. Screen-reader only. */
|
|
41
|
+
announceAnswerReady: string;
|
|
42
|
+
/** Announced when a card is waiting for the user's decision. Token: `{count}`. */
|
|
43
|
+
announceAwaitingDecision: string;
|
|
44
|
+
/** Announced when the user stopped the run. Screen-reader only. */
|
|
45
|
+
announceStopped: string;
|
|
46
|
+
/** Announced when the run failed. Screen-reader only. */
|
|
47
|
+
announceFailed: string;
|
|
36
48
|
/** `aria-label` of the "thinking" pending indicator, and the thoughts region's
|
|
37
49
|
* header while the model is still reasoning. */
|
|
38
50
|
thinking: string;
|
|
@@ -48,6 +60,10 @@ export interface UiStrings {
|
|
|
48
60
|
declinedAction: string;
|
|
49
61
|
/** A navigating tool's card text while the page reloads. */
|
|
50
62
|
navigating: string;
|
|
63
|
+
/** Notice shown when the server replaced the conversation wholesale. */
|
|
64
|
+
historyReplaced: string;
|
|
65
|
+
/** Notice shown when a pushed chart could not be drawn and was removed. */
|
|
66
|
+
chartUndrawable: string;
|
|
51
67
|
/** Missing-placeholder skill hint. Tokens: `{title}`, `{fields}`. */
|
|
52
68
|
skillNeeds: string;
|
|
53
69
|
/** Notice shown when the agent condensed earlier turns. Token: `{count}`. */
|
|
@@ -80,6 +96,12 @@ export interface UiStrings {
|
|
|
80
96
|
transcribing: string;
|
|
81
97
|
/** Mic button fallback message when transcription fails. */
|
|
82
98
|
transcriptionFailed: string;
|
|
99
|
+
/**
|
|
100
|
+
* Mic button message after a recording hit its length cap and stopped itself.
|
|
101
|
+
* The clip is kept and transcribed, so this explains the silence rather than
|
|
102
|
+
* reporting a loss. Token: `{n}` (the cap, in minutes).
|
|
103
|
+
*/
|
|
104
|
+
recordingLimit: string;
|
|
83
105
|
|
|
84
106
|
// ── Tool-call card ──────────────────────────────────────────────────────────
|
|
85
107
|
/** Status pill while the call runs. */
|
|
@@ -110,8 +132,32 @@ export interface UiStrings {
|
|
|
110
132
|
details: string;
|
|
111
133
|
|
|
112
134
|
// ── Confirmation card ───────────────────────────────────────────────────────
|
|
135
|
+
/** `aria-label` of the editable arguments field on an approval card. */
|
|
136
|
+
approvalEditArgs: string;
|
|
137
|
+
/** Shown when the edited arguments are not valid JSON. */
|
|
138
|
+
approvalArgsInvalid: string;
|
|
139
|
+
/** Shown when the edited arguments parse but are not a JSON object. */
|
|
140
|
+
approvalArgsNotAnObject: string;
|
|
141
|
+
/** `aria-label` of the follow-up suggestion chips row. */
|
|
142
|
+
suggestions: string;
|
|
143
|
+
/** `aria-label` of a message's action row. */
|
|
144
|
+
messageActions: string;
|
|
145
|
+
/** The offer that floats beside a selection in the transcript. */
|
|
146
|
+
quoteSelection: string;
|
|
147
|
+
/** Copy this message (button `title` / `aria-label`). Its confirmation and
|
|
148
|
+
* failure text are the code block's `copied` / `copyFailed`, which say the
|
|
149
|
+
* same thing about the same clipboard. */
|
|
150
|
+
copyMessage: string;
|
|
151
|
+
/** Ask for a different answer to the same question. */
|
|
152
|
+
retryMessage: string;
|
|
153
|
+
/** Rate this answer as good. */
|
|
154
|
+
feedbackUp: string;
|
|
155
|
+
/** Rate this answer as poor. */
|
|
156
|
+
feedbackDown: string;
|
|
113
157
|
/** `aria-label` of the inline confirmation card. */
|
|
114
158
|
confirmAction: string;
|
|
159
|
+
/** Waive confirmation for this tool for the rest of the session. Token: `{tool}`. */
|
|
160
|
+
confirmAlways: string;
|
|
115
161
|
/** Generic confirmation prompt when a tool has no `x-confirm`. Token: `{tool}`. */
|
|
116
162
|
confirmRun: string;
|
|
117
163
|
/** Confirm button. */
|
|
@@ -171,20 +217,16 @@ export interface UiStrings {
|
|
|
171
217
|
/** Remove-attachment button `aria-label`. */
|
|
172
218
|
removeAttachment: string;
|
|
173
219
|
|
|
174
|
-
// ──
|
|
175
|
-
/** Under a minute ago. */
|
|
176
|
-
justNow: string;
|
|
177
|
-
/** Minutes ago. Token: `{n}`. */
|
|
178
|
-
minutesAgo: string;
|
|
179
|
-
/** Hours ago. Token: `{n}`. */
|
|
180
|
-
hoursAgo: string;
|
|
181
|
-
/** Title of the checkpoint panel. */
|
|
220
|
+
// ── Code blocks ─────────────────────────────────────────────────────────────
|
|
182
221
|
/** Label on a code block's copy button. */
|
|
183
222
|
copyCode: string;
|
|
184
223
|
/** Shown on the copy button after the code reached the clipboard. */
|
|
185
224
|
copied: string;
|
|
186
225
|
/** Shown when the clipboard was unavailable or refused the write. */
|
|
187
226
|
copyFailed: string;
|
|
227
|
+
|
|
228
|
+
// ── Checkpoint panel (continue a run) ───────────────────────────────────────
|
|
229
|
+
/** Title of the checkpoint panel. */
|
|
188
230
|
checkpoints: string;
|
|
189
231
|
/** Empty state when no run can be continued. */
|
|
190
232
|
noCheckpoints: string;
|
|
@@ -194,6 +236,14 @@ export interface UiStrings {
|
|
|
194
236
|
forkRun: string;
|
|
195
237
|
/** Badge on a run that branched from another. */
|
|
196
238
|
forkedRun: string;
|
|
239
|
+
|
|
240
|
+
// ── Relative time (drawer rows) ─────────────────────────────────────────────
|
|
241
|
+
/** Under a minute ago. */
|
|
242
|
+
justNow: string;
|
|
243
|
+
/** Minutes ago. Token: `{n}`. */
|
|
244
|
+
minutesAgo: string;
|
|
245
|
+
/** Hours ago. Token: `{n}`. */
|
|
246
|
+
hoursAgo: string;
|
|
197
247
|
/** Days ago. Token: `{n}`. */
|
|
198
248
|
daysAgo: string;
|
|
199
249
|
/** Weeks ago. Token: `{n}`. */
|
|
@@ -219,6 +269,12 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
|
|
|
219
269
|
forkedRun: "branched",
|
|
220
270
|
|
|
221
271
|
conversation: "Conversation",
|
|
272
|
+
jumpToLatest: "Jump to latest",
|
|
273
|
+
announceResponding: "Assistant is responding",
|
|
274
|
+
announceAnswerReady: "Assistant answered",
|
|
275
|
+
announceAwaitingDecision: "{count} action is waiting for your approval",
|
|
276
|
+
announceStopped: "Response stopped",
|
|
277
|
+
announceFailed: "The response failed",
|
|
222
278
|
thinking: "Assistant is thinking…",
|
|
223
279
|
thoughts: "Thoughts",
|
|
224
280
|
stopped: "⏹ Stopped",
|
|
@@ -226,6 +282,9 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
|
|
|
226
282
|
noResult: "No result returned.",
|
|
227
283
|
declinedAction: "User declined the action.",
|
|
228
284
|
navigating: "Navigating…",
|
|
285
|
+
historyReplaced:
|
|
286
|
+
"The server replaced this conversation's history. Reload to see the updated transcript.",
|
|
287
|
+
chartUndrawable: "A chart could not be drawn from the data sent, so it was removed.",
|
|
229
288
|
historyCompacted: "Earlier turns condensed to fit the context window ({count} removed)",
|
|
230
289
|
usingSkill: "Using skill {name}",
|
|
231
290
|
runInterrupted: "The previous response didn’t finish — the page changed before it arrived.",
|
|
@@ -244,6 +303,7 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
|
|
|
244
303
|
stopRecording: "Stop recording",
|
|
245
304
|
transcribing: "Transcribing…",
|
|
246
305
|
transcriptionFailed: "Transcription failed",
|
|
306
|
+
recordingLimit: "Stopped at the {n}-minute limit — transcribing what was recorded.",
|
|
247
307
|
|
|
248
308
|
toolRunning: "running…",
|
|
249
309
|
toolDeferred: "waiting for you",
|
|
@@ -259,7 +319,18 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
|
|
|
259
319
|
declinedLabel: "Declined",
|
|
260
320
|
details: "Details",
|
|
261
321
|
|
|
322
|
+
approvalEditArgs: "Edit the arguments before approving",
|
|
323
|
+
approvalArgsInvalid: "That is not valid JSON, so nothing was sent.",
|
|
324
|
+
approvalArgsNotAnObject: "Arguments have to be a JSON object.",
|
|
325
|
+
suggestions: "Suggested follow-ups",
|
|
326
|
+
messageActions: "Message actions",
|
|
327
|
+
quoteSelection: "Quote",
|
|
328
|
+
copyMessage: "Copy message",
|
|
329
|
+
retryMessage: "Try again",
|
|
330
|
+
feedbackUp: "Good answer",
|
|
331
|
+
feedbackDown: "Poor answer",
|
|
262
332
|
confirmAction: "Confirm action",
|
|
333
|
+
confirmAlways: "Always allow",
|
|
263
334
|
confirmRun: "Run “{tool}”?",
|
|
264
335
|
confirm: "Confirm",
|
|
265
336
|
cancel: "Cancel",
|