@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +663 -1
  2. package/README.md +557 -11
  3. package/dist/ag-ui-web-component.bundle.js +294 -36
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +69 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +262 -1
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +46 -1
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/core/conversation_store.d.ts +43 -1
  12. package/dist/core/conversation_store.d.ts.map +1 -1
  13. package/dist/core/create_http_agent.d.ts +13 -0
  14. package/dist/core/create_http_agent.d.ts.map +1 -1
  15. package/dist/core/remote_conversation_store.d.ts +23 -1
  16. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  17. package/dist/core/utils.d.ts +28 -0
  18. package/dist/core/utils.d.ts.map +1 -1
  19. package/dist/index.d.ts +7 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2104 -194
  22. package/dist/index.js.map +4 -4
  23. package/dist/tools/is_destructive.d.ts +8 -2
  24. package/dist/tools/is_destructive.d.ts.map +1 -1
  25. package/dist/tools/parse_tool_catalog.d.ts +11 -4
  26. package/dist/tools/parse_tool_catalog.d.ts.map +1 -1
  27. package/dist/ui/approval_card.d.ts +18 -0
  28. package/dist/ui/approval_card.d.ts.map +1 -1
  29. package/dist/ui/checkpoint_menu.d.ts +10 -0
  30. package/dist/ui/checkpoint_menu.d.ts.map +1 -1
  31. package/dist/ui/confirmation_card.d.ts +16 -0
  32. package/dist/ui/confirmation_card.d.ts.map +1 -1
  33. package/dist/ui/message_actions.d.ts +46 -0
  34. package/dist/ui/message_actions.d.ts.map +1 -0
  35. package/dist/ui/page_quote_offer.d.ts +33 -0
  36. package/dist/ui/page_quote_offer.d.ts.map +1 -0
  37. package/dist/ui/quote_selection.d.ts +66 -0
  38. package/dist/ui/quote_selection.d.ts.map +1 -0
  39. package/dist/ui/relative_time.d.ts +10 -0
  40. package/dist/ui/relative_time.d.ts.map +1 -1
  41. package/dist/ui/render_markdown.d.ts +23 -5
  42. package/dist/ui/render_markdown.d.ts.map +1 -1
  43. package/dist/ui/resize_handle.d.ts +5 -1
  44. package/dist/ui/resize_handle.d.ts.map +1 -1
  45. package/dist/ui/stick_to_bottom.d.ts +55 -0
  46. package/dist/ui/stick_to_bottom.d.ts.map +1 -0
  47. package/dist/ui/styles.d.ts +1 -1
  48. package/dist/ui/styles.d.ts.map +1 -1
  49. package/dist/ui/suggestion_chips.d.ts +29 -0
  50. package/dist/ui/suggestion_chips.d.ts.map +1 -0
  51. package/dist/ui/thread_drawer.d.ts +10 -0
  52. package/dist/ui/thread_drawer.d.ts.map +1 -1
  53. package/dist/ui/tool_call_card.d.ts +8 -0
  54. package/dist/ui/tool_call_card.d.ts.map +1 -1
  55. package/dist/ui/ui_strings.d.ts +53 -7
  56. package/dist/ui/ui_strings.d.ts.map +1 -1
  57. package/dist/ui/voice_input.d.ts.map +1 -1
  58. package/package.json +1 -1
  59. package/src/constants.ts +75 -0
  60. package/src/core/ag_ui_chat.ts +1357 -113
  61. package/src/core/agui_client.ts +81 -1
  62. package/src/core/conversation_store.ts +128 -42
  63. package/src/core/create_http_agent.ts +24 -2
  64. package/src/core/remote_conversation_store.ts +35 -2
  65. package/src/core/utils.ts +58 -0
  66. package/src/index.ts +39 -0
  67. package/src/tools/is_destructive.ts +8 -2
  68. package/src/tools/parse_tool_catalog.ts +18 -6
  69. package/src/ui/approval_card.ts +90 -2
  70. package/src/ui/checkpoint_menu.ts +22 -5
  71. package/src/ui/confirmation_card.ts +29 -1
  72. package/src/ui/message_actions.ts +158 -0
  73. package/src/ui/page_quote_offer.ts +215 -0
  74. package/src/ui/quote_selection.ts +345 -0
  75. package/src/ui/relative_time.ts +11 -0
  76. package/src/ui/render_markdown.ts +111 -21
  77. package/src/ui/resize_handle.ts +32 -2
  78. package/src/ui/stick_to_bottom.ts +126 -0
  79. package/src/ui/styles.ts +227 -0
  80. package/src/ui/suggestion_chips.ts +73 -0
  81. package/src/ui/thread_drawer.ts +22 -2
  82. package/src/ui/tool_call_card.ts +9 -0
  83. package/src/ui/ui_strings.ts +79 -8
  84. package/src/ui/voice_input.ts +43 -0
  85. 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
+ }
@@ -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 = relativeTime(meta.updatedAt, undefined, this.#strings);
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");
@@ -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";
@@ -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
- // ── Relative time (drawer rows) ─────────────────────────────────────────────
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",