@artooi/ag-ui-web-component 0.33.1 → 0.35.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 +563 -1
- package/README.md +279 -16
- package/dist/ag-ui-web-component.bundle.js +676 -101
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +80 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +37 -2
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/dom/animations.d.ts +14 -0
- package/dist/dom/animations.d.ts.map +1 -1
- package/dist/dom/highlight_overlay.d.ts +47 -0
- package/dist/dom/highlight_overlay.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2288 -388
- package/dist/index.js.map +4 -4
- package/dist/tools/chat_surface_tools.d.ts +96 -0
- package/dist/tools/chat_surface_tools.d.ts.map +1 -0
- package/dist/tools/page_action_tools.d.ts +2 -0
- package/dist/tools/page_action_tools.d.ts.map +1 -1
- package/dist/ui/chart_block.d.ts +18 -0
- package/dist/ui/chart_block.d.ts.map +1 -1
- package/dist/ui/clamp_launcher.d.ts +10 -5
- package/dist/ui/clamp_launcher.d.ts.map +1 -1
- package/dist/ui/clamp_panel.d.ts +13 -0
- package/dist/ui/clamp_panel.d.ts.map +1 -0
- package/dist/ui/launcher_drag.d.ts +2 -2
- package/dist/ui/launcher_drag.d.ts.map +1 -1
- package/dist/ui/launcher_placement.d.ts +14 -1
- package/dist/ui/launcher_placement.d.ts.map +1 -1
- package/dist/ui/panel_drag.d.ts +40 -0
- package/dist/ui/panel_drag.d.ts.map +1 -0
- package/dist/ui/place_widget.d.ts +31 -0
- package/dist/ui/place_widget.d.ts.map +1 -0
- package/dist/ui/run_notice.d.ts +14 -3
- package/dist/ui/run_notice.d.ts.map +1 -1
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/thread_drawer.d.ts +21 -0
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +16 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +87 -0
- package/src/core/ag_ui_chat.ts +1146 -38
- package/src/dom/animations.ts +30 -0
- package/src/dom/highlight_overlay.ts +256 -0
- package/src/index.ts +12 -0
- package/src/tools/chat_surface_tools.ts +207 -0
- package/src/tools/page_action_tools.ts +2 -0
- package/src/ui/chart_block.ts +222 -57
- package/src/ui/clamp_launcher.ts +25 -7
- package/src/ui/clamp_panel.ts +30 -0
- package/src/ui/launcher_drag.ts +11 -2
- package/src/ui/launcher_placement.ts +50 -60
- package/src/ui/panel_drag.ts +138 -0
- package/src/ui/place_widget.ts +64 -0
- package/src/ui/run_notice.ts +32 -3
- package/src/ui/styles.ts +622 -47
- package/src/ui/thread_drawer.ts +138 -8
- package/src/ui/ui_strings.ts +24 -0
- package/src/version.ts +1 -1
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -7,25 +7,31 @@ import {
|
|
|
7
7
|
COMPACTION_ACTIVITY_TYPE,
|
|
8
8
|
CUSTOM_AGENT_EVENT,
|
|
9
9
|
DEFAULT_ATTACHMENT_MAX_BYTES,
|
|
10
|
+
EDGE_MARGIN,
|
|
10
11
|
FEEDBACK_EVENT,
|
|
11
12
|
ICON_ATTACH,
|
|
12
13
|
ICON_LAUNCHER,
|
|
14
|
+
ICON_MOON,
|
|
13
15
|
ICON_RETRY,
|
|
14
16
|
ICON_SEND,
|
|
15
17
|
ICON_STOP,
|
|
18
|
+
ICON_SUN,
|
|
16
19
|
INVALIDATE_CUSTOM_NAME,
|
|
17
20
|
INVALIDATE_EVENT,
|
|
18
21
|
LOAD_CAPABILITY_TOOL,
|
|
19
22
|
MAX_TOOL_ROUNDS,
|
|
20
23
|
MESSAGE_ACTIONS,
|
|
21
24
|
MESSAGE_ROLE,
|
|
25
|
+
PASTE_ATTACH_CHARS,
|
|
22
26
|
READ_PAGE_TOOL,
|
|
23
27
|
RUN_FINISHED_EVENT,
|
|
28
|
+
SCREEN_EDGE_MARGIN,
|
|
24
29
|
STATE_EVENT,
|
|
25
30
|
SUBAGENT_CUSTOM_NAME,
|
|
26
31
|
SUBAGENT_PHASE,
|
|
27
32
|
SUBMIT_EVENT,
|
|
28
33
|
SUGGESTIONS_ACTIVITY_TYPE,
|
|
34
|
+
THREADS_DOCK_MIN_WIDTH,
|
|
29
35
|
TOGGLE_EVENT,
|
|
30
36
|
TOOL_CALL_STATUS,
|
|
31
37
|
TOOL_DISPLAY,
|
|
@@ -36,11 +42,20 @@ import {
|
|
|
36
42
|
import { fillTemplate } from "../skills/fill_template.js";
|
|
37
43
|
import { parseSkills } from "../skills/parse_skills.js";
|
|
38
44
|
import type { Skill } from "../skills/skill.js";
|
|
45
|
+
import {
|
|
46
|
+
type ChatCorner,
|
|
47
|
+
type ChatSurfaceReport,
|
|
48
|
+
createChatSurfaceTools,
|
|
49
|
+
} from "../tools/chat_surface_tools.js";
|
|
39
50
|
import type { ChartRenderer } from "../tools/client_tool_registry.js";
|
|
40
51
|
import { type ClientTool, ClientToolRegistry } from "../tools/client_tool_registry.js";
|
|
41
52
|
import { isDestructive } from "../tools/is_destructive.js";
|
|
42
53
|
import { isNavigates } from "../tools/is_navigates.js";
|
|
43
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
createPageActionTools,
|
|
56
|
+
PAGE_ACTIONS,
|
|
57
|
+
type ResolvePageTarget,
|
|
58
|
+
} from "../tools/page_action_tools.js";
|
|
44
59
|
import { createPageMapContext, type PageMap } from "../tools/page_map.js";
|
|
45
60
|
import { createPageStateTools, type PageState } from "../tools/page_state.js";
|
|
46
61
|
import { parseToolCatalog, type ToolCatalogEntry } from "../tools/parse_tool_catalog.js";
|
|
@@ -58,6 +73,7 @@ import { chartSpecFrom } from "../ui/chart_spec_from.js";
|
|
|
58
73
|
import { CHART_TOOL_NAME, createChartTool } from "../ui/chart_tool.js";
|
|
59
74
|
import { CheckpointMenu, type CheckpointVerb } from "../ui/checkpoint_menu.js";
|
|
60
75
|
import { clampLauncher } from "../ui/clamp_launcher.js";
|
|
76
|
+
import { clampPanel } from "../ui/clamp_panel.js";
|
|
61
77
|
import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
|
|
62
78
|
import { copyPayload } from "../ui/copy_payload.js";
|
|
63
79
|
import { enableLauncherDrag } from "../ui/launcher_drag.js";
|
|
@@ -66,6 +82,7 @@ import {
|
|
|
66
82
|
type Extent,
|
|
67
83
|
type LauncherBox,
|
|
68
84
|
launcherPlacement,
|
|
85
|
+
type ViewportBox,
|
|
69
86
|
} from "../ui/launcher_placement.js";
|
|
70
87
|
import {
|
|
71
88
|
attachMessageActions,
|
|
@@ -73,6 +90,8 @@ import {
|
|
|
73
90
|
messageActionButton,
|
|
74
91
|
} from "../ui/message_actions.js";
|
|
75
92
|
import { attachQuoteOffer, type PageQuoteOffer } from "../ui/page_quote_offer.js";
|
|
93
|
+
import { enablePanelDrag } from "../ui/panel_drag.js";
|
|
94
|
+
import { placeWidget } from "../ui/place_widget.js";
|
|
76
95
|
import { prettifyToolName } from "../ui/prettify_tool_name.js";
|
|
77
96
|
import {
|
|
78
97
|
type QuestionRenderer,
|
|
@@ -316,6 +335,15 @@ const THEME_KEY = "ag-ui-chat:theme";
|
|
|
316
335
|
/** Per-tab persistence key for a dragged launcher position. */
|
|
317
336
|
const LAUNCHER_KEY = "ag-ui-chat:launcher";
|
|
318
337
|
|
|
338
|
+
/** A stored `{ left, top }` pair, or null for anything that is not one. */
|
|
339
|
+
function asPoint(value: unknown): { readonly left: number; readonly top: number } | null {
|
|
340
|
+
if (typeof value !== "object" || value === null) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
const { left, top } = value as { left?: unknown; top?: unknown };
|
|
344
|
+
return typeof left === "number" && typeof top === "number" ? { left, top } : null;
|
|
345
|
+
}
|
|
346
|
+
|
|
319
347
|
/**
|
|
320
348
|
* Placements whose launcher can be dragged. The rest have nowhere to put it:
|
|
321
349
|
* a sidebar collapses to a full-height edge rail, "embedded" and "page" hide
|
|
@@ -618,7 +646,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
618
646
|
* Resolve a `scroll_to` / `drag_and_drop` target string to a host-page
|
|
619
647
|
* element (or `null`). Defaults to a CSS-selector lookup; override to map
|
|
620
648
|
* page-map element ids. The page-action tools are opt-in via the
|
|
621
|
-
* `data-page-actions` attribute (`"scroll"` / `"drag"`).
|
|
649
|
+
* `data-page-actions` attribute (`"scroll"` / `"drag"` / `"chat"`).
|
|
622
650
|
*/
|
|
623
651
|
resolvePageTarget: ResolvePageTarget = (target) => document.querySelector<HTMLElement>(target);
|
|
624
652
|
|
|
@@ -743,6 +771,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
743
771
|
readonly #root: ShadowRoot;
|
|
744
772
|
/** Screen-reader-only status region -- see {@link AgUiChat.#announce}. */
|
|
745
773
|
readonly #announcer = document.createElement("div");
|
|
774
|
+
/**
|
|
775
|
+
* A zero-sized box carrying the host's viewport insets as padding, so they
|
|
776
|
+
* can be read back as used pixel lengths. See the `.viewport-probe` rule for
|
|
777
|
+
* why a custom property cannot be read directly.
|
|
778
|
+
*/
|
|
779
|
+
readonly #viewportProbe = document.createElement("div");
|
|
746
780
|
/** Return-to-foot affordance, shown only once something has been missed. */
|
|
747
781
|
readonly #jumpButton = document.createElement("button");
|
|
748
782
|
/**
|
|
@@ -804,6 +838,8 @@ export class AgUiChat extends HTMLElement {
|
|
|
804
838
|
readonly #launcher: HTMLButtonElement;
|
|
805
839
|
/** The launcher's unread badge; hidden at zero, and when the host opts out. */
|
|
806
840
|
readonly #badge: HTMLSpanElement;
|
|
841
|
+
/** The rail's vertical caption. Rendered only by the sidebar's edge rail. */
|
|
842
|
+
readonly #railLabel: HTMLSpanElement = document.createElement("span");
|
|
807
843
|
// Answers that finished while the widget was collapsed. Expanding clears it.
|
|
808
844
|
#unread = 0;
|
|
809
845
|
/** Empty-state region at the top of the message list; hidden once anything renders. */
|
|
@@ -817,6 +853,27 @@ export class AgUiChat extends HTMLElement {
|
|
|
817
853
|
* position -- see #applyLauncherPlacement for what that costs the host.
|
|
818
854
|
*/
|
|
819
855
|
#launcherPos: { readonly left: number; readonly top: number } | null = null;
|
|
856
|
+
/** What the user has sent this session, newest first, for arrow-key recall. */
|
|
857
|
+
readonly #sentDrafts: string[] = [];
|
|
858
|
+
/** Typed while a run was in flight, oldest first; sent when it settles. */
|
|
859
|
+
readonly #queued: string[] = [];
|
|
860
|
+
/** The row those show up in, above the composer. */
|
|
861
|
+
readonly #queuedRow: HTMLDivElement = document.createElement("div");
|
|
862
|
+
/** How far back the composer has been walked; null while the user is typing. */
|
|
863
|
+
#recallIndex: number | null = null;
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Where the user dragged the *panel*, in viewport coordinates, or null while
|
|
867
|
+
* its position is still derived from the launcher's.
|
|
868
|
+
*
|
|
869
|
+
* The two gestures state different things and are restored differently. A
|
|
870
|
+
* launcher drag says where the bubble goes and leaves the panel to open into
|
|
871
|
+
* whatever space the viewport has, so it is re-derived every time -- which is
|
|
872
|
+
* what lets a widget re-decide its direction when the window changes under
|
|
873
|
+
* it. A header drag states the panel's own position, and re-deriving that
|
|
874
|
+
* from the launcher would move the panel the user just placed.
|
|
875
|
+
*/
|
|
876
|
+
#panelPos: { readonly left: number; readonly top: number } | null = null;
|
|
820
877
|
|
|
821
878
|
/**
|
|
822
879
|
* The corner the panel opens away from, once this element is placing itself.
|
|
@@ -839,8 +896,78 @@ export class AgUiChat extends HTMLElement {
|
|
|
839
896
|
* a field so `removeEventListener` on disconnect gets the same reference.
|
|
840
897
|
*/
|
|
841
898
|
readonly #onViewportResize = (): void => {
|
|
899
|
+
this.#publishVisualViewport();
|
|
900
|
+
// Not while a gesture owns the position. Restoring re-applies the *stored*
|
|
901
|
+
// position, and mid-drag that value is the one from before the drag began
|
|
902
|
+
// -- so it puts the widget back where it was, the next pointer move puts it
|
|
903
|
+
// where the finger is, and the two fight for as long as the viewport keeps
|
|
904
|
+
// changing.
|
|
905
|
+
//
|
|
906
|
+
// Which on a phone can be most of the drag: the visual viewport resizes and
|
|
907
|
+
// scrolls whenever the browser's own chrome collapses, and that is driven
|
|
908
|
+
// by the gesture in progress. This was not the cause of the jumping that
|
|
909
|
+
// sent me looking -- that was an inset measured from the wrong box -- so it
|
|
910
|
+
// is a guard against a fight that had not been observed rather than a fix
|
|
911
|
+
// for one that had.
|
|
912
|
+
if (this.#dragging()) {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
842
915
|
this.#restoreLauncherPosition();
|
|
916
|
+
// Docking is decided by width, so a resize can cross the threshold with
|
|
917
|
+
// the drawer already open. Without this the rail keeps a narrow
|
|
918
|
+
// transcript's width, the focus trap stays off, and the backdrop that
|
|
919
|
+
// would dismiss it is still display:none. Only while it is open: the two
|
|
920
|
+
// are re-decided on the way in, and a closed drawer has no layout to fix.
|
|
921
|
+
if (this.#drawer.isOpen()) {
|
|
922
|
+
this.#drawer.setModal(!this.#threadsDock());
|
|
923
|
+
this.#syncThreadsState();
|
|
924
|
+
}
|
|
843
925
|
};
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Hold a resized box inside the part of the screen the host left free.
|
|
929
|
+
*
|
|
930
|
+
* Each edge on its own, unlike the drag's clamp: a drag moves a box of fixed
|
|
931
|
+
* size, so pushing it back in is right, while a resize is anchored on the
|
|
932
|
+
* opposite edge and pushing it back would move the edge the user is not
|
|
933
|
+
* touching. Bounding each edge instead leaves the grip stopped at the limit
|
|
934
|
+
* -- the gesture keeps going and the panel simply stops growing, which is
|
|
935
|
+
* what dragging already does.
|
|
936
|
+
*
|
|
937
|
+
* The minimum size is the grip's own concern and is applied before this, so
|
|
938
|
+
* a panel that cannot fit the space is left at its minimum and overflowing
|
|
939
|
+
* rather than collapsed to nothing.
|
|
940
|
+
*/
|
|
941
|
+
#withinViewport(box: PanelRect): PanelRect {
|
|
942
|
+
const viewport = this.#viewport();
|
|
943
|
+
// The same bound a drag stops at, so a grip pulled to the edge and a panel
|
|
944
|
+
// dragged to it come to rest on the same line. The inner Math.max/min pair
|
|
945
|
+
// keeps an already-inverted box from turning inside out.
|
|
946
|
+
const left = viewport.left + SCREEN_EDGE_MARGIN;
|
|
947
|
+
const top = viewport.top + SCREEN_EDGE_MARGIN;
|
|
948
|
+
const right = viewport.left + viewport.width - SCREEN_EDGE_MARGIN;
|
|
949
|
+
const bottom = viewport.top + viewport.height - SCREEN_EDGE_MARGIN;
|
|
950
|
+
return {
|
|
951
|
+
left: Math.min(Math.max(box.left, left), box.right),
|
|
952
|
+
top: Math.min(Math.max(box.top, top), box.bottom),
|
|
953
|
+
right: Math.max(Math.min(box.right, right), box.left),
|
|
954
|
+
bottom: Math.max(Math.min(box.bottom, bottom), box.top),
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Whether a pointer or key gesture is currently placing the widget.
|
|
960
|
+
*
|
|
961
|
+
* Read from the stamp the drag helpers already set, rather than tracked
|
|
962
|
+
* separately: one source of truth, and it clears on `pointercancel` as well
|
|
963
|
+
* as `pointerup`, which is the end a touch gesture usually gets.
|
|
964
|
+
*/
|
|
965
|
+
#dragging(): boolean {
|
|
966
|
+
return (
|
|
967
|
+
this.#launcher.hasAttribute("data-dragging") ||
|
|
968
|
+
this.#root.querySelector(".header[data-dragging]") !== null
|
|
969
|
+
);
|
|
970
|
+
}
|
|
844
971
|
/** Mic button mount point (input row); the control mounts on connect when enabled. */
|
|
845
972
|
readonly #voiceSlot: HTMLSpanElement;
|
|
846
973
|
/** Voice-input control; created on connect when transcription is available. */
|
|
@@ -975,6 +1102,9 @@ export class AgUiChat extends HTMLElement {
|
|
|
975
1102
|
onDelete: (threadId) => {
|
|
976
1103
|
this.#deleteThread(threadId);
|
|
977
1104
|
},
|
|
1105
|
+
onVisibility: () => {
|
|
1106
|
+
this.#syncThreadsState();
|
|
1107
|
+
},
|
|
978
1108
|
});
|
|
979
1109
|
this.#checkpoints = new CheckpointMenu((runId, verb) => {
|
|
980
1110
|
void this.#continueRun(runId, verb);
|
|
@@ -1080,6 +1210,13 @@ export class AgUiChat extends HTMLElement {
|
|
|
1080
1210
|
// Position is owned the same way a size is: a placement that places
|
|
1081
1211
|
// itself takes back a launcher the user had dragged somewhere else.
|
|
1082
1212
|
this.#releaseLauncherPosition();
|
|
1213
|
+
// Switching into a placement with no collapsed state has to release it,
|
|
1214
|
+
// not just stop offering it: the control is gone from the header the
|
|
1215
|
+
// moment the attribute changes, so a panel collapsed under the previous
|
|
1216
|
+
// placement would have no way back.
|
|
1217
|
+
if (!this.#collapsible() && this.collapsed) {
|
|
1218
|
+
this.setCollapsed(false);
|
|
1219
|
+
}
|
|
1083
1220
|
// Placement also moves the panel, so the edges its layout holds still
|
|
1084
1221
|
// change with it. Deferred a frame so the new rules have applied.
|
|
1085
1222
|
requestAnimationFrame(() => this.#syncResizeAnchor());
|
|
@@ -1089,6 +1226,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1089
1226
|
// `#strings` is the resolved table once connected, the English defaults
|
|
1090
1227
|
// before then.
|
|
1091
1228
|
this.#title.textContent = value ?? this.#strings.title;
|
|
1229
|
+
this.#railLabel.textContent = this.#title.textContent;
|
|
1092
1230
|
return;
|
|
1093
1231
|
}
|
|
1094
1232
|
if (name === "user-key") {
|
|
@@ -1227,7 +1365,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
1227
1365
|
.map((token) => token.trim())
|
|
1228
1366
|
.filter((token) => token !== ""),
|
|
1229
1367
|
);
|
|
1230
|
-
return
|
|
1368
|
+
return [
|
|
1369
|
+
...createPageActionTools(enabled, (target) => this.resolvePageTarget(target)),
|
|
1370
|
+
...(enabled.has(PAGE_ACTIONS.CHAT) ? createChatSurfaceTools(this) : []),
|
|
1371
|
+
];
|
|
1231
1372
|
}
|
|
1232
1373
|
|
|
1233
1374
|
/** All built-in (route + page + page-action + ask_user) frontend tools. */
|
|
@@ -1518,7 +1659,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1518
1659
|
// Restore a theme the built-in toggle persisted last visit (opt-in only, so
|
|
1519
1660
|
// it never overrides a host that drives `theme` itself).
|
|
1520
1661
|
if (this.getAttribute("data-theme-toggle") !== null) {
|
|
1521
|
-
const saved = this.#
|
|
1662
|
+
const saved = this.#readPreference(THEME_KEY);
|
|
1522
1663
|
if (saved !== null) {
|
|
1523
1664
|
this.setAttribute("theme", saved);
|
|
1524
1665
|
}
|
|
@@ -1526,7 +1667,11 @@ export class AgUiChat extends HTMLElement {
|
|
|
1526
1667
|
this.#render();
|
|
1527
1668
|
this.#drawer.setStrings(this.#strings);
|
|
1528
1669
|
this.#checkpoints.setStrings(this.#strings);
|
|
1529
|
-
|
|
1670
|
+
// Gated on the placement, not just on the stored value: the key is
|
|
1671
|
+
// namespaced per instance but not per placement, so a tab that collapsed a
|
|
1672
|
+
// floating panel and later loaded the same instance as a page would restore
|
|
1673
|
+
// a state that placement has no way out of.
|
|
1674
|
+
if (this.#collapsible() && this.#startsCollapsed()) {
|
|
1530
1675
|
this.setAttribute("collapsed", "");
|
|
1531
1676
|
}
|
|
1532
1677
|
this.#syncLauncher();
|
|
@@ -1542,6 +1687,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
1542
1687
|
this.conversationStore = this.#builtinStore;
|
|
1543
1688
|
}
|
|
1544
1689
|
window.addEventListener("resize", this.#onViewportResize);
|
|
1690
|
+
// The visual viewport changes without the window resizing -- a keyboard
|
|
1691
|
+
// opening, a pinch-zoom, the URL bar collapsing -- and `scroll` is what
|
|
1692
|
+
// fires when it is panned rather than resized.
|
|
1693
|
+
window.visualViewport?.addEventListener("resize", this.#onViewportResize);
|
|
1694
|
+
window.visualViewport?.addEventListener("scroll", this.#onViewportResize);
|
|
1695
|
+
this.#publishVisualViewport();
|
|
1545
1696
|
this.#wireThreadStore();
|
|
1546
1697
|
this.#wireAttachments();
|
|
1547
1698
|
this.#wireVoice();
|
|
@@ -1615,6 +1766,8 @@ export class AgUiChat extends HTMLElement {
|
|
|
1615
1766
|
disconnectedCallback(): void {
|
|
1616
1767
|
this.#connected = false;
|
|
1617
1768
|
window.removeEventListener("resize", this.#onViewportResize);
|
|
1769
|
+
window.visualViewport?.removeEventListener("resize", this.#onViewportResize);
|
|
1770
|
+
window.visualViewport?.removeEventListener("scroll", this.#onViewportResize);
|
|
1618
1771
|
// Give the namespace back. A disconnect is not necessarily a farewell — a
|
|
1619
1772
|
// DOM move and a framework re-render both look like one — and an element
|
|
1620
1773
|
// that could not reclaim its own namespace on the way back in would lose
|
|
@@ -1699,7 +1852,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1699
1852
|
this.#fileInput.accept = accept;
|
|
1700
1853
|
this.#attachButton.hidden = false;
|
|
1701
1854
|
this.#enableDragAndDrop();
|
|
1702
|
-
this.#enablePaste();
|
|
1855
|
+
this.#enablePaste(tray);
|
|
1703
1856
|
}
|
|
1704
1857
|
|
|
1705
1858
|
/** The built-in multipart upload handler for `data-attachments-url`, or `null`. */
|
|
@@ -1967,6 +2120,65 @@ export class AgUiChat extends HTMLElement {
|
|
|
1967
2120
|
});
|
|
1968
2121
|
}
|
|
1969
2122
|
|
|
2123
|
+
/**
|
|
2124
|
+
* Turn a very long text paste into an attachment instead of a wall of text.
|
|
2125
|
+
*
|
|
2126
|
+
* A composer capped at `40vh` is not where forty thousand characters go: the
|
|
2127
|
+
* user cannot read what they pasted, cannot edit around it, and sends one
|
|
2128
|
+
* enormous turn. As a file it stays whole, the model still receives it, and
|
|
2129
|
+
* the box is left for the question about it.
|
|
2130
|
+
*
|
|
2131
|
+
* Only where the host has configured uploads -- and structurally so, rather
|
|
2132
|
+
* than by a check here: the paste listener is wired inside the attachment
|
|
2133
|
+
* setup, so with no tray there is no listener at all and an ordinary paste is
|
|
2134
|
+
* untouched. Quietly dropping a paste for being long would be far worse than
|
|
2135
|
+
* an awkward composer. The tray is passed rather than read off the field for
|
|
2136
|
+
* the same reason its `onChange` hook is: it can only be called from one that
|
|
2137
|
+
* exists, so taking it as an argument removes a null check no caller can
|
|
2138
|
+
* reach.
|
|
2139
|
+
*
|
|
2140
|
+
* Nothing is lost by removing the chip: the text is still on the clipboard,
|
|
2141
|
+
* so pasting again brings it back. That is why this needs no undo of its own.
|
|
2142
|
+
*/
|
|
2143
|
+
#pasteLongTextAsFile(event: ClipboardEvent, clipboard: DataTransfer, tray: AttachmentTray): void {
|
|
2144
|
+
const threshold = this.#pasteAttachThreshold();
|
|
2145
|
+
const text = clipboard.getData("text/plain");
|
|
2146
|
+
if (threshold === null || text.length < threshold) {
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
event.preventDefault();
|
|
2150
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2151
|
+
tray.add(new File([text], `pasted-${stamp}.txt`, { type: "text/plain" }));
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
/**
|
|
2155
|
+
* How long a pasted string has to be before it becomes a file, or `null` to
|
|
2156
|
+
* leave every paste in the composer.
|
|
2157
|
+
*
|
|
2158
|
+
* One attribute with three answers rather than three attributes: absent is
|
|
2159
|
+
* the default, `off` refuses, and a number states the threshold. A value that
|
|
2160
|
+
* is neither says so, because a typo silently meaning "off" is the failure
|
|
2161
|
+
* this whole release keeps finding.
|
|
2162
|
+
*/
|
|
2163
|
+
#pasteAttachThreshold(): number | null {
|
|
2164
|
+
const raw = this.getAttribute("data-paste-attach");
|
|
2165
|
+
if (raw === null) {
|
|
2166
|
+
return PASTE_ATTACH_CHARS;
|
|
2167
|
+
}
|
|
2168
|
+
if (raw === "off") {
|
|
2169
|
+
return null;
|
|
2170
|
+
}
|
|
2171
|
+
const stated = Number.parseInt(raw, 10);
|
|
2172
|
+
if (Number.isNaN(stated) || stated <= 0) {
|
|
2173
|
+
console.warn(
|
|
2174
|
+
`<ag-ui-chat>: data-paste-attach="${raw}" is neither "off" nor a positive ` +
|
|
2175
|
+
`number of characters, so the default of ${PASTE_ATTACH_CHARS} is used.`,
|
|
2176
|
+
);
|
|
2177
|
+
return PASTE_ATTACH_CHARS;
|
|
2178
|
+
}
|
|
2179
|
+
return stated;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
1970
2182
|
/**
|
|
1971
2183
|
* Accept files pasted into the composer.
|
|
1972
2184
|
*
|
|
@@ -1980,7 +2192,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1980
2192
|
* clipboard, and swallowing the words someone meant to paste in order to
|
|
1981
2193
|
* attach a picture they did not is the worse of the two failures.
|
|
1982
2194
|
*/
|
|
1983
|
-
#enablePaste(): void {
|
|
2195
|
+
#enablePaste(tray: AttachmentTray): void {
|
|
1984
2196
|
this.#chat.addEventListener("paste", (event: ClipboardEvent) => {
|
|
1985
2197
|
// Nullish rather than a null check: the property is typed as nullable,
|
|
1986
2198
|
// and an engine that fires a plain Event for a paste leaves it absent
|
|
@@ -1991,6 +2203,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1991
2203
|
}
|
|
1992
2204
|
const files = Array.from(clipboard.files);
|
|
1993
2205
|
if (files.length === 0) {
|
|
2206
|
+
this.#pasteLongTextAsFile(event, clipboard, tray);
|
|
1994
2207
|
return;
|
|
1995
2208
|
}
|
|
1996
2209
|
if (clipboard.getData("text/plain") === "") {
|
|
@@ -2176,7 +2389,27 @@ export class AgUiChat extends HTMLElement {
|
|
|
2176
2389
|
* per-tab, and emit a {@link TOGGLE_EVENT} so a host can mirror the state in
|
|
2177
2390
|
* its own chrome.
|
|
2178
2391
|
*/
|
|
2179
|
-
setCollapsed(collapsed: boolean): void {
|
|
2392
|
+
setCollapsed(collapsed: boolean, options: { readonly announce?: boolean } = {}): void {
|
|
2393
|
+
if (collapsed && !this.#collapsible()) {
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
// Announced before the state changes, so the notice is written into a
|
|
2397
|
+
// transcript the user can still see -- and only when the agent did it,
|
|
2398
|
+
// rather than when the user pressed the control themselves.
|
|
2399
|
+
//
|
|
2400
|
+
// Collapsing only. Expanding announces nothing because the panel arriving
|
|
2401
|
+
// is the announcement, and a notice about something visibly happening is
|
|
2402
|
+
// noise; there is also nothing to undo that the collapse control does not
|
|
2403
|
+
// already do.
|
|
2404
|
+
if (options.announce === true && collapsed && !this.collapsed) {
|
|
2405
|
+
// No undo beside this one. The notice is written into the transcript and
|
|
2406
|
+
// the transcript is then hidden by the very collapse it describes, so
|
|
2407
|
+
// the only way to read it is to expand the panel -- which is what the
|
|
2408
|
+
// undo would have done. By the time the button can be seen it has
|
|
2409
|
+
// nothing left to do, and a control that is always a no-op is worse than
|
|
2410
|
+
// no control. Same reasoning as the expand case just below.
|
|
2411
|
+
this.#announceSurfaceChange(this.#strings.chatMinimised, null);
|
|
2412
|
+
}
|
|
2180
2413
|
if (!collapsed) {
|
|
2181
2414
|
// Re-decide which way to open before opening: the viewport may have
|
|
2182
2415
|
// changed since the launcher was dropped, and this corner is what the
|
|
@@ -2211,6 +2444,194 @@ export class AgUiChat extends HTMLElement {
|
|
|
2211
2444
|
return this.#unread;
|
|
2212
2445
|
}
|
|
2213
2446
|
|
|
2447
|
+
/**
|
|
2448
|
+
* Whether to mount collapsed.
|
|
2449
|
+
*
|
|
2450
|
+
* A stored choice always wins -- in either direction, so a user who opened
|
|
2451
|
+
* the panel finds it open. With nothing stored, the corner placements start
|
|
2452
|
+
* collapsed: they are the two that have a launcher, and a launcher is the
|
|
2453
|
+
* resting state of every corner chat in the field. Mounting open put a
|
|
2454
|
+
* 380x560 panel over the host page's own bottom-right corner on a visitor's
|
|
2455
|
+
* first load, uninvited.
|
|
2456
|
+
*
|
|
2457
|
+
* The placements that place themselves are unchanged. A host that docks a
|
|
2458
|
+
* sidebar has already decided the widget belongs on screen, and one that
|
|
2459
|
+
* embeds it in its own layout has given it a box to fill.
|
|
2460
|
+
*
|
|
2461
|
+
* `data-start-open` restores the previous behaviour for a host that wants
|
|
2462
|
+
* the panel up immediately.
|
|
2463
|
+
*/
|
|
2464
|
+
#startsCollapsed(): boolean {
|
|
2465
|
+
const stored = this.#readScopedItem(COLLAPSED_KEY);
|
|
2466
|
+
if (stored !== null) {
|
|
2467
|
+
return stored === "1";
|
|
2468
|
+
}
|
|
2469
|
+
return (
|
|
2470
|
+
DRAGGABLE_PLACEMENTS.has(this.getAttribute("placement")) &&
|
|
2471
|
+
!this.hasAttribute("data-start-open")
|
|
2472
|
+
);
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
/**
|
|
2476
|
+
* Whether this placement has a collapsed state at all.
|
|
2477
|
+
*
|
|
2478
|
+
* `page` does not. It is a dedicated route rather than a panel sitting on
|
|
2479
|
+
* someone else's page, so there is no "away" for it to go to: collapsing it
|
|
2480
|
+
* left a strip of application chrome fixed over a route that no longer had an
|
|
2481
|
+
* owner. It is also the placement that hides the launcher, so the usual way
|
|
2482
|
+
* back does not exist here.
|
|
2483
|
+
*
|
|
2484
|
+
* The header hides its collapse control under this placement, but a control
|
|
2485
|
+
* removed from the UI is not a state removed from the model -- the property,
|
|
2486
|
+
* the attribute and a value restored from storage all still reach it. This is
|
|
2487
|
+
* what the reachable paths are gated on; the stylesheet covers the one path
|
|
2488
|
+
* that never passes through here, an attribute written straight onto the
|
|
2489
|
+
* element.
|
|
2490
|
+
*/
|
|
2491
|
+
#collapsible(): boolean {
|
|
2492
|
+
return this.getAttribute("placement") !== "page";
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
/**
|
|
2496
|
+
* Describe the panel to whoever is asking -- in practice the agent, through
|
|
2497
|
+
* the opt-in chat-surface tools.
|
|
2498
|
+
*
|
|
2499
|
+
* `movable` folds two separate reasons into the one answer a caller needs:
|
|
2500
|
+
* a placement that places itself owns its position, and a panel that fills
|
|
2501
|
+
* the screen has nowhere to go. Reporting them apart would make every caller
|
|
2502
|
+
* re-derive the same conjunction.
|
|
2503
|
+
*/
|
|
2504
|
+
describeSurface(): ChatSurfaceReport {
|
|
2505
|
+
const box = this.getBoundingClientRect();
|
|
2506
|
+
const viewport = this.#viewport();
|
|
2507
|
+
const fullBleed = box.width >= viewport.width - 1 && box.height >= viewport.height - 1;
|
|
2508
|
+
return {
|
|
2509
|
+
placement: this.getAttribute("placement"),
|
|
2510
|
+
collapsed: this.collapsed,
|
|
2511
|
+
collapsible: this.#collapsible(),
|
|
2512
|
+
movable: this.#launcherDraggable() && !fullBleed,
|
|
2513
|
+
draggable: this.#launcherDraggable(),
|
|
2514
|
+
fullBleed,
|
|
2515
|
+
box: {
|
|
2516
|
+
left: Math.round(box.left),
|
|
2517
|
+
top: Math.round(box.top),
|
|
2518
|
+
width: Math.round(box.width),
|
|
2519
|
+
height: Math.round(box.height),
|
|
2520
|
+
},
|
|
2521
|
+
viewport: {
|
|
2522
|
+
left: Math.round(viewport.left),
|
|
2523
|
+
top: Math.round(viewport.top),
|
|
2524
|
+
width: Math.round(viewport.width),
|
|
2525
|
+
height: Math.round(viewport.height),
|
|
2526
|
+
},
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
/**
|
|
2531
|
+
* Send the panel to a corner, and report whether it went.
|
|
2532
|
+
*
|
|
2533
|
+
* A third claimant on the axes a placement and a user drag already share, and
|
|
2534
|
+
* it takes them the same way the drag does rather than inventing a second
|
|
2535
|
+
* mechanism: the same commit path, so the launcher travels with the panel,
|
|
2536
|
+
* the corner it opens from is re-picked, and switching placement hands
|
|
2537
|
+
* everything back. What it must not do is claim a move it did not make --
|
|
2538
|
+
* hence the boolean, and hence {@link describeSurface} existing so a caller
|
|
2539
|
+
* can ask first.
|
|
2540
|
+
*/
|
|
2541
|
+
moveTo(corner: ChatCorner, options: { readonly announce?: boolean } = {}): boolean {
|
|
2542
|
+
if (!this.#launcherDraggable()) {
|
|
2543
|
+
return false;
|
|
2544
|
+
}
|
|
2545
|
+
const restore = options.announce === true ? this.#captureGeometry() : null;
|
|
2546
|
+
const viewport = this.#viewport();
|
|
2547
|
+
const box = this.getBoundingClientRect();
|
|
2548
|
+
if (box.width >= viewport.width - 1 && box.height >= viewport.height - 1) {
|
|
2549
|
+
return false;
|
|
2550
|
+
}
|
|
2551
|
+
const [edgeY, edgeX] = corner.split("-");
|
|
2552
|
+
// Every term is an absolute screen coordinate, because that is what the
|
|
2553
|
+
// clamps and the insets both speak. The usable box carries an origin, so
|
|
2554
|
+
// its near edge is `viewport.left`, not zero, and its far edge is
|
|
2555
|
+
// `viewport.left + viewport.width` -- a margin applied to the extents
|
|
2556
|
+
// alone would send the agent's own move under the chrome the host
|
|
2557
|
+
// reserved, which is the failure the usable box exists to prevent.
|
|
2558
|
+
const nearX = viewport.left + EDGE_MARGIN;
|
|
2559
|
+
const nearY = viewport.top + EDGE_MARGIN;
|
|
2560
|
+
const left =
|
|
2561
|
+
edgeX === "left"
|
|
2562
|
+
? nearX
|
|
2563
|
+
: Math.max(nearX, viewport.left + viewport.width - EDGE_MARGIN - box.width);
|
|
2564
|
+
const top =
|
|
2565
|
+
edgeY === "top"
|
|
2566
|
+
? nearY
|
|
2567
|
+
: Math.max(nearY, viewport.top + viewport.height - EDGE_MARGIN - box.height);
|
|
2568
|
+
const host = { left, top, right: left + box.width, bottom: top + box.height };
|
|
2569
|
+
// Both axes measured, rather than one read twice: a host can restyle the
|
|
2570
|
+
// launcher as a pill, and squaring it here would put it off the corner.
|
|
2571
|
+
const launcherWidth = this.#launcher.offsetWidth;
|
|
2572
|
+
const launcherHeight = this.#launcher.offsetHeight;
|
|
2573
|
+
this.#placePanelAndLauncher(host, {
|
|
2574
|
+
left: edgeX === "left" ? host.left : host.right - launcherWidth,
|
|
2575
|
+
top: edgeY === "top" ? host.top : host.bottom - launcherHeight,
|
|
2576
|
+
width: launcherWidth,
|
|
2577
|
+
height: launcherHeight,
|
|
2578
|
+
});
|
|
2579
|
+
this.#storeLauncherPosition();
|
|
2580
|
+
if (restore !== null) {
|
|
2581
|
+
this.#announceSurfaceChange(this.#strings.chatMoved, restore);
|
|
2582
|
+
}
|
|
2583
|
+
return true;
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
/**
|
|
2587
|
+
* Snapshot the panel's stated position, and return a function that puts it
|
|
2588
|
+
* back.
|
|
2589
|
+
*
|
|
2590
|
+
* Both insets and the expand corner, because they are one decision: the
|
|
2591
|
+
* corner is what the panel grows from, so restoring a position without it
|
|
2592
|
+
* puts the box back and animates it out of the wrong side. Absent values are
|
|
2593
|
+
* captured as absent and removed on the way back, rather than written as
|
|
2594
|
+
* empty strings that would outrank the placement.
|
|
2595
|
+
*/
|
|
2596
|
+
#captureGeometry(): () => void {
|
|
2597
|
+
const inset = this.style.getPropertyValue("--ag-ui-inset");
|
|
2598
|
+
const launcherInset = this.style.getPropertyValue("--ag-ui-launcher-inset");
|
|
2599
|
+
const corner = this.getAttribute("data-expand-corner");
|
|
2600
|
+
const launcherPos = this.#launcherPos;
|
|
2601
|
+
const panelPos = this.#panelPos;
|
|
2602
|
+
const expandCorner = this.#expandCorner;
|
|
2603
|
+
return () => {
|
|
2604
|
+
const put = (name: string, value: string): void => {
|
|
2605
|
+
if (value === "") {
|
|
2606
|
+
this.style.removeProperty(name);
|
|
2607
|
+
} else {
|
|
2608
|
+
this.style.setProperty(name, value);
|
|
2609
|
+
}
|
|
2610
|
+
};
|
|
2611
|
+
put("--ag-ui-inset", inset);
|
|
2612
|
+
put("--ag-ui-launcher-inset", launcherInset);
|
|
2613
|
+
if (corner === null) {
|
|
2614
|
+
this.removeAttribute("data-expand-corner");
|
|
2615
|
+
} else {
|
|
2616
|
+
this.setAttribute("data-expand-corner", corner);
|
|
2617
|
+
}
|
|
2618
|
+
this.#launcherPos = launcherPos;
|
|
2619
|
+
this.#panelPos = panelPos;
|
|
2620
|
+
this.#expandCorner = expandCorner;
|
|
2621
|
+
// Erased rather than rewritten when there was nothing to go back to.
|
|
2622
|
+
// #storeLauncherPosition returns early for a null position, which would
|
|
2623
|
+
// leave the move this is undoing sitting in storage -- and since that
|
|
2624
|
+
// store outlives the tab, the next resize or reload would quietly put
|
|
2625
|
+
// the panel back in the corner the user had just rejected.
|
|
2626
|
+
if (launcherPos === null) {
|
|
2627
|
+
this.#clearPreference(LAUNCHER_KEY);
|
|
2628
|
+
} else {
|
|
2629
|
+
this.#storeLauncherPosition();
|
|
2630
|
+
}
|
|
2631
|
+
this.#syncResizeAnchor();
|
|
2632
|
+
};
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2214
2635
|
/** Flip the collapsed state. Bound to the built-in header toggle. */
|
|
2215
2636
|
toggleCollapsed(): void {
|
|
2216
2637
|
this.setCollapsed(!this.collapsed);
|
|
@@ -2225,7 +2646,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
2225
2646
|
toggleTheme(): void {
|
|
2226
2647
|
const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
|
|
2227
2648
|
this.setAttribute("theme", next);
|
|
2228
|
-
|
|
2649
|
+
this.#writePreference(THEME_KEY, next);
|
|
2229
2650
|
this.#syncThemeGlyph();
|
|
2230
2651
|
}
|
|
2231
2652
|
|
|
@@ -2356,6 +2777,13 @@ export class AgUiChat extends HTMLElement {
|
|
|
2356
2777
|
if (axis === "none") {
|
|
2357
2778
|
return;
|
|
2358
2779
|
}
|
|
2780
|
+
// The placement's max-width and max-height are left alone, which means a
|
|
2781
|
+
// grip pushed against the edge the placement is *not* anchored to stops one
|
|
2782
|
+
// gutter short of the screen. Moving the cap with the size fixes that and
|
|
2783
|
+
// shifts several resting sizes by a pixel or two, because the cap and the
|
|
2784
|
+
// size are not measured from the same box -- not worth the churn for a
|
|
2785
|
+
// symmetry nobody has asked for. The limit that matters, staying inside
|
|
2786
|
+
// what the host left free, is enforced above.
|
|
2359
2787
|
if (size.width !== undefined) {
|
|
2360
2788
|
this.style.setProperty("--ag-ui-width", `${size.width}px`);
|
|
2361
2789
|
}
|
|
@@ -2393,9 +2821,113 @@ export class AgUiChat extends HTMLElement {
|
|
|
2393
2821
|
);
|
|
2394
2822
|
}
|
|
2395
2823
|
|
|
2396
|
-
/**
|
|
2397
|
-
|
|
2398
|
-
|
|
2824
|
+
/**
|
|
2825
|
+
* The viewport the launcher and the panel both have to fit inside.
|
|
2826
|
+
*
|
|
2827
|
+
* The *visual* viewport, not the layout one, because they come apart exactly
|
|
2828
|
+
* when this matters. An on-screen keyboard shrinks the visual viewport and
|
|
2829
|
+
* leaves the layout viewport alone, so clamping against `innerHeight` parks
|
|
2830
|
+
* the launcher behind the keyboard and decides which corner to open into
|
|
2831
|
+
* using space that is not on the screen. Pinch-zoom does the same on both
|
|
2832
|
+
* axes.
|
|
2833
|
+
*
|
|
2834
|
+
* Falls back where the API is absent, which keeps this working in the
|
|
2835
|
+
* happy-dom project as well as in an old browser.
|
|
2836
|
+
*/
|
|
2837
|
+
#viewport(): ViewportBox {
|
|
2838
|
+
const visual = window.visualViewport;
|
|
2839
|
+
const width = visual?.width ?? window.innerWidth;
|
|
2840
|
+
const height = visual?.height ?? window.innerHeight;
|
|
2841
|
+
// And minus whatever the host reserved for its own chrome. Without this a
|
|
2842
|
+
// panel is clamped against the whole screen and settles happily underneath
|
|
2843
|
+
// a sticky header, where it cannot be reached -- and where collapsing it,
|
|
2844
|
+
// the one thing a user tries, hides it completely rather than rescuing it.
|
|
2845
|
+
// Read as padding off the probe, not as custom properties off this
|
|
2846
|
+
// element. `getPropertyValue` on an unregistered custom property returns
|
|
2847
|
+
// the token stream rather than a length, so a host stating `4rem` reserves
|
|
2848
|
+
// four pixels here and sixty-four in the stylesheet, and one stating
|
|
2849
|
+
// `calc(56px + env(safe-area-inset-top))` -- which is the natural spelling
|
|
2850
|
+
// of what the token's own documentation recommends -- parses as NaN and
|
|
2851
|
+
// takes the panel's whole inset down with it.
|
|
2852
|
+
const style = getComputedStyle(this.#viewportProbe);
|
|
2853
|
+
const edge = (name: string): number => {
|
|
2854
|
+
const value = Number.parseFloat(style.getPropertyValue(name));
|
|
2855
|
+
// A detached or not-yet-rendered probe resolves to nothing at all, and
|
|
2856
|
+
// reserving NaN is worse than reserving zero in every case.
|
|
2857
|
+
return Number.isFinite(value) ? value : 0;
|
|
2858
|
+
};
|
|
2859
|
+
const left = edge("padding-left");
|
|
2860
|
+
const top = edge("padding-top");
|
|
2861
|
+
return {
|
|
2862
|
+
left,
|
|
2863
|
+
top,
|
|
2864
|
+
width: Math.max(0, width - left - edge("padding-right")),
|
|
2865
|
+
height: Math.max(0, height - top - edge("padding-bottom")),
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
/**
|
|
2870
|
+
* The whole viewport, before anything the host reserved is taken out of it.
|
|
2871
|
+
*
|
|
2872
|
+
* Distinct from {@link #viewport} on purpose, and the two must not be
|
|
2873
|
+
* swapped. The usable box decides where the widget may rest; this is what a
|
|
2874
|
+
* CSS `inset` on a fixed element is measured from, because that is what the
|
|
2875
|
+
* browser measures it from.
|
|
2876
|
+
*/
|
|
2877
|
+
#screen(): Extent {
|
|
2878
|
+
// The *layout* viewport, and `clientWidth`/`clientHeight` rather than
|
|
2879
|
+
// `innerWidth`/`innerHeight`, because those two disagree by the width of a
|
|
2880
|
+
// classic scrollbar and it is the smaller one a fixed element is laid out
|
|
2881
|
+
// against. Reading the visual viewport here would be the same mistake one
|
|
2882
|
+
// level up as clamping against the whole screen was one level down: a
|
|
2883
|
+
// keyboard shrinks the visual viewport without moving the box CSS measures
|
|
2884
|
+
// an inset from, so a panel the clamp had just held inside the visible
|
|
2885
|
+
// band would be written back out behind the keyboard.
|
|
2886
|
+
//
|
|
2887
|
+
// The zero checks are for a detached or not-yet-laid-out document, where
|
|
2888
|
+
// `clientWidth` is 0 and no viewport ever is.
|
|
2889
|
+
const root = document.documentElement;
|
|
2890
|
+
return {
|
|
2891
|
+
width: root.clientWidth || window.innerWidth,
|
|
2892
|
+
height: root.clientHeight || window.innerHeight,
|
|
2893
|
+
};
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
/**
|
|
2897
|
+
* Publish the measured viewport height so the stylesheet can size a
|
|
2898
|
+
* full-bleed panel to what the user can see.
|
|
2899
|
+
*
|
|
2900
|
+
* No CSS length carries this. An on-screen keyboard has no effect on any
|
|
2901
|
+
* viewport-percentage unit, so a panel sized from `100dvh` puts its composer
|
|
2902
|
+
* behind the keyboard being typed into. Written inline, and read through a
|
|
2903
|
+
* token the host's own `--ag-ui-viewport-height` still outranks.
|
|
2904
|
+
*
|
|
2905
|
+
* Removed rather than frozen when the two viewports agree, so a desktop that
|
|
2906
|
+
* never diverges carries no inline override at all and the declared fallback
|
|
2907
|
+
* stays in charge.
|
|
2908
|
+
*/
|
|
2909
|
+
#publishVisualViewport(): void {
|
|
2910
|
+
const visual = window.visualViewport;
|
|
2911
|
+
if (visual === null || visual === undefined) {
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
if (Math.abs(visual.height - window.innerHeight) < 1) {
|
|
2915
|
+
this.style.removeProperty("--ag-ui-visual-viewport-height");
|
|
2916
|
+
this.style.removeProperty("--ag-ui-visual-viewport-inset-bottom");
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
this.style.setProperty("--ag-ui-visual-viewport-height", `${Math.round(visual.height)}px`);
|
|
2920
|
+
// What is hidden below the visible area, which is where a keyboard is. A
|
|
2921
|
+
// shorter panel does not help anything anchored to the bottom: a floating
|
|
2922
|
+
// widget is positioned against the layout viewport, so its bottom edge and
|
|
2923
|
+
// the launcher at that corner stay behind the keyboard until this lifts
|
|
2924
|
+
// them. Never negative -- a visual viewport panned up past the layout one
|
|
2925
|
+
// would otherwise pull the panel down off the screen.
|
|
2926
|
+
const hidden = window.innerHeight - visual.height - visual.offsetTop;
|
|
2927
|
+
this.style.setProperty(
|
|
2928
|
+
"--ag-ui-visual-viewport-inset-bottom",
|
|
2929
|
+
`${Math.max(0, Math.round(hidden))}px`,
|
|
2930
|
+
);
|
|
2399
2931
|
}
|
|
2400
2932
|
|
|
2401
2933
|
/**
|
|
@@ -2444,6 +2976,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
2444
2976
|
return;
|
|
2445
2977
|
}
|
|
2446
2978
|
this.#launcherPos = at;
|
|
2979
|
+
// Dropping the bubble hands the panel's position back to the placement.
|
|
2980
|
+
// Keeping a stated one would pin the panel where it was dragged and leave
|
|
2981
|
+
// the launcher deriving nothing, which is the gesture doing half its job.
|
|
2982
|
+
this.#panelPos = null;
|
|
2447
2983
|
// The host box keeps its expanded size while collapsed, so its own rect is
|
|
2448
2984
|
// the panel's size in either state and needs no separate bookkeeping.
|
|
2449
2985
|
const panel = this.getBoundingClientRect();
|
|
@@ -2451,6 +2987,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
2451
2987
|
this.#launcherBox(),
|
|
2452
2988
|
{ width: panel.width, height: panel.height },
|
|
2453
2989
|
this.#viewport(),
|
|
2990
|
+
this.#screen(),
|
|
2454
2991
|
);
|
|
2455
2992
|
this.style.setProperty("--ag-ui-inset", placement.hostInset);
|
|
2456
2993
|
this.style.setProperty("--ag-ui-launcher-inset", placement.launcherInset);
|
|
@@ -2471,13 +3008,184 @@ export class AgUiChat extends HTMLElement {
|
|
|
2471
3008
|
this.#storeLauncherPosition();
|
|
2472
3009
|
}
|
|
2473
3010
|
|
|
2474
|
-
/**
|
|
3011
|
+
/**
|
|
3012
|
+
* Move the panel live during a header drag, without persisting.
|
|
3013
|
+
*
|
|
3014
|
+
* Only the host box is written, and that is the whole trick: the launcher is
|
|
3015
|
+
* positioned *inside* that box, so leaving its own inset alone carries it
|
|
3016
|
+
* along by exactly the distance the panel travelled -- which is what a person
|
|
3017
|
+
* dragging a window expects of the thing it collapses into. Placing it on the
|
|
3018
|
+
* panel's pinned corner instead, as an earlier version did, sent it leaping
|
|
3019
|
+
* across the panel the moment the drag re-picked that corner.
|
|
3020
|
+
*
|
|
3021
|
+
* The corner is therefore held for the length of the gesture. Both insets are
|
|
3022
|
+
* measured from it, and rewriting one of them from a new corner while the
|
|
3023
|
+
* other still names the old one would move the launcher for no reason.
|
|
3024
|
+
*/
|
|
3025
|
+
#movePanel(box: PanelRect, from: PanelRect): { held: PanelRect; launcher: LauncherBox | null } {
|
|
3026
|
+
if (!this.#launcherDraggable()) {
|
|
3027
|
+
return { held: box, launcher: null };
|
|
3028
|
+
}
|
|
3029
|
+
// Where the launcher rests, recorded before the first move writes anything.
|
|
3030
|
+
// From here on the DOM shows it mid-gesture, so this is the last moment it
|
|
3031
|
+
// can be read rather than derived.
|
|
3032
|
+
if (this.#launcherPos === null) {
|
|
3033
|
+
const resting = this.#launcherBox();
|
|
3034
|
+
this.#launcherPos = { left: resting.left, top: resting.top };
|
|
3035
|
+
}
|
|
3036
|
+
// The launcher as it was when the gesture began. Held for the whole drag:
|
|
3037
|
+
// every move measures from here, so the two halves cannot drift apart.
|
|
3038
|
+
const start = this.#launcherPos;
|
|
3039
|
+
// The screen-edge bound, not the resting gutter. The 24px margin is where
|
|
3040
|
+
// a placement rests one, not a rule about where a person may put it, and
|
|
3041
|
+
// enforcing it against a drag is what made the panel feel stuck short of
|
|
3042
|
+
// every edge on all four sides at once. Zero was the correction and it
|
|
3043
|
+
// went too far the other way: it welded the panel to the boundary while
|
|
3044
|
+
// the launcher -- same shadow, same rounded edge -- was held 8px off it,
|
|
3045
|
+
// and it disagreed with the restore below, so a panel dragged flush leapt
|
|
3046
|
+
// inward the next time the viewport changed.
|
|
3047
|
+
const held = clampPanel(box, this.#viewport(), SCREEN_EDGE_MARGIN);
|
|
3048
|
+
const corner = this.#expandCorner ?? this.#anchor;
|
|
3049
|
+
// The usable box decides where the panel may rest, above; the screen is
|
|
3050
|
+
// what these insets are measured from, because that is what the browser
|
|
3051
|
+
// measures a fixed element's inset from. Using the usable box here made a
|
|
3052
|
+
// right or bottom short by whatever the host had reserved, and the panel
|
|
3053
|
+
// jumped by that much the first time a gesture wrote one.
|
|
3054
|
+
const screen = this.#screen();
|
|
3055
|
+
this.style.setProperty(
|
|
3056
|
+
"--ag-ui-inset",
|
|
3057
|
+
[
|
|
3058
|
+
corner.y === "top" ? `${Math.round(held.top)}px` : "auto",
|
|
3059
|
+
corner.x === "right" ? `${Math.round(screen.width - held.right)}px` : "auto",
|
|
3060
|
+
corner.y === "bottom" ? `${Math.round(screen.height - held.bottom)}px` : "auto",
|
|
3061
|
+
corner.x === "left" ? `${Math.round(held.left)}px` : "auto",
|
|
3062
|
+
].join(" "),
|
|
3063
|
+
);
|
|
3064
|
+
this.#panelPos = { left: held.left, top: held.top };
|
|
3065
|
+
|
|
3066
|
+
// Where the launcher has ended up, derived rather than read. During a
|
|
3067
|
+
// header drag it rides inside the host box with its own inset untouched,
|
|
3068
|
+
// so the DOM shows it moving while `#launcherPos` still holds where it
|
|
3069
|
+
// started -- reading it back mid-gesture returns the stale value, and
|
|
3070
|
+
// adding the panel's travel to that a second time on release is a jump.
|
|
3071
|
+
// Measured from the box the press started on, so a long drag cannot
|
|
3072
|
+
// accumulate the rounding each move writes.
|
|
3073
|
+
const carried = {
|
|
3074
|
+
...this.#launcherBox(),
|
|
3075
|
+
left: start.left + (held.left - from.left),
|
|
3076
|
+
top: start.top + (held.top - from.top),
|
|
3077
|
+
};
|
|
3078
|
+
// A bubble carried into an edge the host reserved is one nobody can press.
|
|
3079
|
+
// Clamping it live rather than at the end is what makes releasing the drag
|
|
3080
|
+
// change nothing: leaving it until then parked it under a nav bar for the
|
|
3081
|
+
// whole gesture and hopped it out on pointerup.
|
|
3082
|
+
const launcher = { ...carried, ...clampLauncher(carried, this.#viewport()) };
|
|
3083
|
+
this.style.setProperty(
|
|
3084
|
+
"--ag-ui-launcher-inset",
|
|
3085
|
+
placeWidget(held, launcher, corner, this.#screen()).launcherInset,
|
|
3086
|
+
);
|
|
3087
|
+
return { held, launcher };
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
/**
|
|
3091
|
+
* Finish a header drag: settle where both halves ended up, and remember it.
|
|
3092
|
+
*
|
|
3093
|
+
* The launcher travels the distance the panel actually travelled, which is
|
|
3094
|
+
* the clamped distance rather than the pointer's -- a panel held against the
|
|
3095
|
+
* viewport margin stops, and so does the bubble attached to it. Measured from
|
|
3096
|
+
* the box the press started on, so a long drag cannot accumulate the rounding
|
|
3097
|
+
* each move writes into the inset.
|
|
3098
|
+
*
|
|
3099
|
+
* Only now is the corner re-picked, from where the launcher has ended up, so
|
|
3100
|
+
* the panel opens into clear space next time. Re-picking it moves nothing:
|
|
3101
|
+
* both insets are rewritten from positions that are already decided.
|
|
3102
|
+
*/
|
|
3103
|
+
#commitPanel(box: PanelRect, from: PanelRect): void {
|
|
3104
|
+
// Exactly what the last move applied, rather than the same sum computed
|
|
3105
|
+
// again. Recomputing it is how the two came apart: releasing the drag
|
|
3106
|
+
// moved the bubble by the panel's whole travel a second time.
|
|
3107
|
+
const { held, launcher } = this.#movePanel(box, from);
|
|
3108
|
+
if (launcher === null) {
|
|
3109
|
+
return;
|
|
3110
|
+
}
|
|
3111
|
+
this.#placePanelAndLauncher(held, launcher);
|
|
3112
|
+
this.#storeLauncherPosition();
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
/**
|
|
3116
|
+
* Write both insets for a panel and launcher that are already positioned,
|
|
3117
|
+
* re-picking the corner they are measured from.
|
|
3118
|
+
*/
|
|
3119
|
+
#placePanelAndLauncher(host: PanelRect, launcher: LauncherBox): void {
|
|
3120
|
+
const viewport = this.#viewport();
|
|
3121
|
+
const screen = this.#screen();
|
|
3122
|
+
const size = { width: host.right - host.left, height: host.bottom - host.top };
|
|
3123
|
+
const { corner } = launcherPlacement(launcher, size, viewport, screen);
|
|
3124
|
+
// The screen again, not the usable box: these are CSS insets on a fixed
|
|
3125
|
+
// element and the browser measures them from the real edges.
|
|
3126
|
+
const insets = placeWidget(host, launcher, corner, screen);
|
|
3127
|
+
this.style.setProperty("--ag-ui-inset", insets.hostInset);
|
|
3128
|
+
this.style.setProperty("--ag-ui-launcher-inset", insets.launcherInset);
|
|
3129
|
+
this.#launcherPos = { left: launcher.left, top: launcher.top };
|
|
3130
|
+
this.#panelPos = { left: host.left, top: host.top };
|
|
3131
|
+
this.#expandCorner = corner;
|
|
3132
|
+
this.setAttribute("data-expand-corner", `${corner.y}-${corner.x}`);
|
|
3133
|
+
this.#syncResizeAnchor();
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
/**
|
|
3137
|
+
* Re-apply a panel position the user stated, against the current viewport.
|
|
3138
|
+
*
|
|
3139
|
+
* The launcher keeps its offset from the panel through the clamp -- it was
|
|
3140
|
+
* put where it is relative to the panel, and a viewport that has since shrunk
|
|
3141
|
+
* is no reason to move one without the other -- and is then held on screen in
|
|
3142
|
+
* its own right.
|
|
3143
|
+
*/
|
|
3144
|
+
#restorePanelPosition(at: { readonly left: number; readonly top: number }): void {
|
|
3145
|
+
if (!this.#launcherDraggable()) {
|
|
3146
|
+
return;
|
|
3147
|
+
}
|
|
3148
|
+
const rect = this.getBoundingClientRect();
|
|
3149
|
+
// The same bound the drag itself used. Taking the default here instead is
|
|
3150
|
+
// what made a panel dragged to an edge jump a whole resting gutter inward
|
|
3151
|
+
// on the next resize, reload or expand -- re-placing a position the user
|
|
3152
|
+
// had stated, against a limit they had never been shown.
|
|
3153
|
+
const held = clampPanel(
|
|
3154
|
+
{ left: at.left, top: at.top, right: at.left + rect.width, bottom: at.top + rect.height },
|
|
3155
|
+
this.#viewport(),
|
|
3156
|
+
SCREEN_EDGE_MARGIN,
|
|
3157
|
+
);
|
|
3158
|
+
const launcher = this.#launcherBox();
|
|
3159
|
+
const carried = {
|
|
3160
|
+
...launcher,
|
|
3161
|
+
left: launcher.left + (held.left - at.left),
|
|
3162
|
+
top: launcher.top + (held.top - at.top),
|
|
3163
|
+
};
|
|
3164
|
+
this.#placePanelAndLauncher(held, {
|
|
3165
|
+
...carried,
|
|
3166
|
+
...clampLauncher(carried, this.#viewport()),
|
|
3167
|
+
});
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
/**
|
|
3171
|
+
* Write the current position, if this element owns one.
|
|
3172
|
+
*
|
|
3173
|
+
* The panel's own position rides along only when the user stated it, because
|
|
3174
|
+
* its presence is what tells a restore which of the two gestures to honour:
|
|
3175
|
+
* with it, the panel goes back where it was put; without it, the panel is
|
|
3176
|
+
* re-derived from the launcher and opens into whatever room the viewport has
|
|
3177
|
+
* now.
|
|
3178
|
+
*/
|
|
2475
3179
|
#storeLauncherPosition(): void {
|
|
2476
3180
|
const position = this.#launcherPos;
|
|
2477
3181
|
if (position === null) {
|
|
2478
3182
|
return;
|
|
2479
3183
|
}
|
|
2480
|
-
|
|
3184
|
+
const panel = this.#panelPos;
|
|
3185
|
+
this.#writePreference(
|
|
3186
|
+
LAUNCHER_KEY,
|
|
3187
|
+
JSON.stringify(panel === null ? position : { ...position, panel }),
|
|
3188
|
+
);
|
|
2481
3189
|
}
|
|
2482
3190
|
|
|
2483
3191
|
/**
|
|
@@ -2487,19 +3195,33 @@ export class AgUiChat extends HTMLElement {
|
|
|
2487
3195
|
* unreachable -- it is the only way back to a collapsed conversation.
|
|
2488
3196
|
*/
|
|
2489
3197
|
#restoreLauncherPosition(): void {
|
|
2490
|
-
const stored = this.#
|
|
2491
|
-
|
|
3198
|
+
const stored = this.#readLauncherPosition();
|
|
3199
|
+
const launcher = this.#launcherPos ?? stored;
|
|
3200
|
+
if (launcher === null) {
|
|
3201
|
+
return;
|
|
3202
|
+
}
|
|
3203
|
+
const panel = this.#panelPos ?? stored?.panel ?? null;
|
|
3204
|
+
if (panel !== null) {
|
|
3205
|
+
// Stated rather than derived: the launcher is only read here to keep the
|
|
3206
|
+
// offset the two were left with, so it has to be seeded before the panel
|
|
3207
|
+
// is placed around it.
|
|
3208
|
+
this.#launcherPos = { left: launcher.left, top: launcher.top };
|
|
3209
|
+
this.#restorePanelPosition(panel);
|
|
2492
3210
|
return;
|
|
2493
3211
|
}
|
|
2494
3212
|
const box = this.#launcherBox();
|
|
2495
3213
|
this.#applyLauncherPlacement(
|
|
2496
|
-
clampLauncher({ ...box, left:
|
|
3214
|
+
clampLauncher({ ...box, left: launcher.left, top: launcher.top }, this.#viewport()),
|
|
2497
3215
|
);
|
|
2498
3216
|
}
|
|
2499
3217
|
|
|
2500
|
-
/** The persisted
|
|
2501
|
-
#readLauncherPosition(): {
|
|
2502
|
-
|
|
3218
|
+
/** The persisted position for this instance, or null. */
|
|
3219
|
+
#readLauncherPosition(): {
|
|
3220
|
+
readonly left: number;
|
|
3221
|
+
readonly top: number;
|
|
3222
|
+
readonly panel?: { readonly left: number; readonly top: number };
|
|
3223
|
+
} | null {
|
|
3224
|
+
const raw = this.#readPreference(LAUNCHER_KEY);
|
|
2503
3225
|
if (raw === null) {
|
|
2504
3226
|
return null;
|
|
2505
3227
|
}
|
|
@@ -2508,8 +3230,15 @@ export class AgUiChat extends HTMLElement {
|
|
|
2508
3230
|
if (typeof parsed !== "object" || parsed === null) {
|
|
2509
3231
|
return null;
|
|
2510
3232
|
}
|
|
2511
|
-
const { left, top } = parsed as { left?: unknown; top?: unknown };
|
|
2512
|
-
|
|
3233
|
+
const { left, top, panel } = parsed as { left?: unknown; top?: unknown; panel?: unknown };
|
|
3234
|
+
if (typeof left !== "number" || typeof top !== "number") {
|
|
3235
|
+
return null;
|
|
3236
|
+
}
|
|
3237
|
+
// A record written before the panel could be dragged has no panel half,
|
|
3238
|
+
// and one written by a launcher drag never will -- both restore by
|
|
3239
|
+
// deriving the panel, which is what they meant.
|
|
3240
|
+
const at = asPoint(panel);
|
|
3241
|
+
return at === null ? { left, top } : { left, top, panel: at };
|
|
2513
3242
|
} catch {
|
|
2514
3243
|
// A corrupt entry is not worth failing a mount over; fall back to the
|
|
2515
3244
|
// placement's own corner.
|
|
@@ -2527,6 +3256,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
2527
3256
|
return;
|
|
2528
3257
|
}
|
|
2529
3258
|
this.#launcherPos = null;
|
|
3259
|
+
this.#panelPos = null;
|
|
2530
3260
|
this.#expandCorner = null;
|
|
2531
3261
|
this.style.removeProperty("--ag-ui-inset");
|
|
2532
3262
|
this.style.removeProperty("--ag-ui-launcher-inset");
|
|
@@ -2547,20 +3277,24 @@ export class AgUiChat extends HTMLElement {
|
|
|
2547
3277
|
* host positioning the panel with its own rule keeps that rule until someone
|
|
2548
3278
|
* drags the edge it was holding.
|
|
2549
3279
|
*/
|
|
2550
|
-
#applyResize(grip: ResizeGrip, box: PanelRect):
|
|
3280
|
+
#applyResize(grip: ResizeGrip, box: PanelRect): PanelRect {
|
|
3281
|
+
box = this.#withinViewport(box);
|
|
2551
3282
|
this.#applySize({ width: box.right - box.left, height: box.bottom - box.top });
|
|
2552
3283
|
if (grip.x !== this.#anchor.x && grip.y !== this.#anchor.y) {
|
|
2553
|
-
return;
|
|
3284
|
+
return box;
|
|
2554
3285
|
}
|
|
2555
|
-
|
|
3286
|
+
// The whole screen, not the usable box: a CSS inset on a fixed element is
|
|
3287
|
+
// measured from the real edges, so expressing a right or bottom against a
|
|
3288
|
+
// box the host has inset comes out short by exactly that inset.
|
|
3289
|
+
const screen = this.#screen();
|
|
2556
3290
|
const anchor = this.#anchor;
|
|
2557
3291
|
const side = (value: number): string => `${Math.round(value)}px`;
|
|
2558
3292
|
this.style.setProperty(
|
|
2559
3293
|
"--ag-ui-inset",
|
|
2560
3294
|
[
|
|
2561
3295
|
anchor.y === "top" ? side(box.top) : "auto",
|
|
2562
|
-
anchor.x === "right" ? side(
|
|
2563
|
-
anchor.y === "bottom" ? side(
|
|
3296
|
+
anchor.x === "right" ? side(screen.width - box.right) : "auto",
|
|
3297
|
+
anchor.y === "bottom" ? side(screen.height - box.bottom) : "auto",
|
|
2564
3298
|
anchor.x === "left" ? side(box.left) : "auto",
|
|
2565
3299
|
].join(" "),
|
|
2566
3300
|
);
|
|
@@ -2575,12 +3309,21 @@ export class AgUiChat extends HTMLElement {
|
|
|
2575
3309
|
top: anchor.y === "top" ? box.top : box.bottom - size,
|
|
2576
3310
|
};
|
|
2577
3311
|
}
|
|
3312
|
+
// A stated panel position is a claim about this box, so it moves with it.
|
|
3313
|
+
if (this.#panelPos !== null) {
|
|
3314
|
+
this.#panelPos = { left: box.left, top: box.top };
|
|
3315
|
+
}
|
|
3316
|
+
return box;
|
|
2578
3317
|
}
|
|
2579
3318
|
|
|
2580
3319
|
/** Finish a resize: keep the box, remember it, and re-read the pinned edges. */
|
|
2581
3320
|
#commitResize(grip: ResizeGrip, box: PanelRect): void {
|
|
2582
|
-
|
|
2583
|
-
|
|
3321
|
+
// The bounded box, not the one the pointer asked for. Persisting the raw
|
|
3322
|
+
// one would store a size the panel never had and restore it on the next
|
|
3323
|
+
// mount, which is the same disagreement between apply and commit that made
|
|
3324
|
+
// the header drag jump on release.
|
|
3325
|
+
const held = this.#applyResize(grip, box);
|
|
3326
|
+
this.#persistSize({ width: held.right - held.left, height: held.bottom - held.top });
|
|
2584
3327
|
this.#storeLauncherPosition();
|
|
2585
3328
|
// Re-stamp after the drag: a host whose layout changed underneath us would
|
|
2586
3329
|
// otherwise keep the tab-reachable grip in the old corner, which reads as
|
|
@@ -2614,12 +3357,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
2614
3357
|
/** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
|
|
2615
3358
|
#persistSize(size: ResizeSize): void {
|
|
2616
3359
|
const stored = { ...this.#readSize(), ...size };
|
|
2617
|
-
|
|
3360
|
+
this.#writePreference(SIZE_KEY, JSON.stringify(stored));
|
|
2618
3361
|
}
|
|
2619
3362
|
|
|
2620
3363
|
/** The persisted size for this instance, or an empty record. */
|
|
2621
3364
|
#readSize(): ResizeSize {
|
|
2622
|
-
const raw = this.#
|
|
3365
|
+
const raw = this.#readPreference(SIZE_KEY);
|
|
2623
3366
|
if (raw === null) {
|
|
2624
3367
|
return {};
|
|
2625
3368
|
}
|
|
@@ -2775,10 +3518,91 @@ export class AgUiChat extends HTMLElement {
|
|
|
2775
3518
|
return sessionStorage.getItem(base);
|
|
2776
3519
|
}
|
|
2777
3520
|
|
|
3521
|
+
/**
|
|
3522
|
+
* Read a layout preference: where the widget sits, how big it is, which
|
|
3523
|
+
* theme it wears.
|
|
3524
|
+
*
|
|
3525
|
+
* These live in `localStorage` rather than beside the transcript, because a
|
|
3526
|
+
* layout preference is not a conversation. The transcript is deliberately
|
|
3527
|
+
* per-tab -- two tabs are two conversations, and closing the tab ends it --
|
|
3528
|
+
* and everything else inherited that scoping without earning it. A user who
|
|
3529
|
+
* dragged the panel clear of their own UI did it again in the next tab, and
|
|
3530
|
+
* again after every restart.
|
|
3531
|
+
*
|
|
3532
|
+
* Whether the widget is *currently open* stays per-tab with the transcript.
|
|
3533
|
+
* It is a statement about this tab rather than a preference: carrying it
|
|
3534
|
+
* across would pop the panel open on every new tab because it was opened
|
|
3535
|
+
* once, somewhere else.
|
|
3536
|
+
*
|
|
3537
|
+
* Falls back to the session value it used to be written to, so an existing
|
|
3538
|
+
* position survives the upgrade rather than resetting once.
|
|
3539
|
+
*/
|
|
3540
|
+
#readPreference(base: string): string | null {
|
|
3541
|
+
try {
|
|
3542
|
+
const stored = localStorage.getItem(this.#storageKey(base));
|
|
3543
|
+
if (stored !== null) {
|
|
3544
|
+
return stored;
|
|
3545
|
+
}
|
|
3546
|
+
} catch {
|
|
3547
|
+
// Fall through to the per-tab copy below.
|
|
3548
|
+
}
|
|
3549
|
+
return this.#readScopedItem(base);
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
/**
|
|
3553
|
+
* Persist a layout preference as durably as this browser allows: to
|
|
3554
|
+
* `localStorage` so it outlives the tab, and to the per-tab store as well.
|
|
3555
|
+
*
|
|
3556
|
+
* The second write is not redundancy for its own sake. A privacy mode can
|
|
3557
|
+
* deny `localStorage` while allowing `sessionStorage`, and losing the
|
|
3558
|
+
* durable copy should degrade to the per-tab behaviour this replaced rather
|
|
3559
|
+
* than to no persistence at all. The read above prefers the durable copy, so
|
|
3560
|
+
* a tab that has both cannot be shadowed by its own stale one.
|
|
3561
|
+
*
|
|
3562
|
+
* Neither write is worth an exception. Losing where the panel sat is not
|
|
3563
|
+
* worth a warning either -- unlike the transcript, which says so once,
|
|
3564
|
+
* because losing that loses the conversation on the next reload.
|
|
3565
|
+
*/
|
|
3566
|
+
#writePreference(base: string, value: string): void {
|
|
3567
|
+
const key = this.#storageKey(base);
|
|
3568
|
+
try {
|
|
3569
|
+
localStorage.setItem(key, value);
|
|
3570
|
+
} catch {
|
|
3571
|
+
// Quota, or a store that denies writes.
|
|
3572
|
+
}
|
|
3573
|
+
writeStoredItem(key, value);
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
/**
|
|
3577
|
+
* Drop a layout preference from both stores.
|
|
3578
|
+
*
|
|
3579
|
+
* The mirror of {@link AgUiChat.#writePreference}, and it has to clear both
|
|
3580
|
+
* for the same reason that writes both: leaving either copy behind means the
|
|
3581
|
+
* value comes back on the next read.
|
|
3582
|
+
*/
|
|
3583
|
+
#clearPreference(base: string): void {
|
|
3584
|
+
const key = this.#storageKey(base);
|
|
3585
|
+
try {
|
|
3586
|
+
localStorage.removeItem(key);
|
|
3587
|
+
} catch {
|
|
3588
|
+
// A store that denies access; the per-tab copy below still goes.
|
|
3589
|
+
}
|
|
3590
|
+
try {
|
|
3591
|
+
sessionStorage.removeItem(key);
|
|
3592
|
+
} catch {
|
|
3593
|
+
// Nothing left to do: the value was never persisted in the first place.
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
|
|
2778
3597
|
/** Reflect the current theme on the toggle: show the destination's glyph. */
|
|
2779
3598
|
#syncThemeGlyph(): void {
|
|
2780
3599
|
const dark = this.getAttribute("theme") === "dark";
|
|
2781
|
-
|
|
3600
|
+
// Replaced wholesale rather than toggling a class: the two marks are
|
|
3601
|
+
// different paths, not one path in two states, and the slot has to keep
|
|
3602
|
+
// working so a host can still supply its own.
|
|
3603
|
+
this.#themeToggle.replaceChildren(
|
|
3604
|
+
this.#iconElement("theme", "theme-icon", dark ? ICON_SUN : ICON_MOON, null),
|
|
3605
|
+
);
|
|
2782
3606
|
}
|
|
2783
3607
|
|
|
2784
3608
|
/**
|
|
@@ -2797,7 +3621,42 @@ export class AgUiChat extends HTMLElement {
|
|
|
2797
3621
|
// open *underneath* a popover still floating over it.
|
|
2798
3622
|
this.#checkpoints.close();
|
|
2799
3623
|
void this.#refreshDrawer();
|
|
3624
|
+
this.#drawer.setModal(!this.#threadsDock());
|
|
2800
3625
|
this.#drawer.open();
|
|
3626
|
+
this.#syncThreadsState();
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
/**
|
|
3630
|
+
* Whether the conversation list docks beside the transcript rather than
|
|
3631
|
+
* covering it.
|
|
3632
|
+
*
|
|
3633
|
+
* Only the full-page placement, and only where there is room. A dedicated
|
|
3634
|
+
* route is the one surface with width to spare -- everywhere else the panel
|
|
3635
|
+
* is a few hundred pixels wide, and a list docked into that leaves a column
|
|
3636
|
+
* of transcript too narrow to read. The width is the panel's own rather than
|
|
3637
|
+
* the window's, because an embedded host can give a full-page-sized box to
|
|
3638
|
+
* something that is not a page.
|
|
3639
|
+
*/
|
|
3640
|
+
#threadsDock(): boolean {
|
|
3641
|
+
return (
|
|
3642
|
+
this.getAttribute("placement") === "page" &&
|
|
3643
|
+
this.getBoundingClientRect().width >= THREADS_DOCK_MIN_WIDTH
|
|
3644
|
+
);
|
|
3645
|
+
}
|
|
3646
|
+
|
|
3647
|
+
/**
|
|
3648
|
+
* Stamp whether the list is showing, and how.
|
|
3649
|
+
*
|
|
3650
|
+
* On the host rather than inside the shell because the transcript has to move
|
|
3651
|
+
* over for a docked list, and the drawer is the last child of the panel -- CSS
|
|
3652
|
+
* cannot select backwards from it to the rows it needs to shift.
|
|
3653
|
+
*/
|
|
3654
|
+
#syncThreadsState(): void {
|
|
3655
|
+
if (this.#drawer.isOpen() && this.#threadsDock()) {
|
|
3656
|
+
this.setAttribute("data-threads-docked", "");
|
|
3657
|
+
} else {
|
|
3658
|
+
this.removeAttribute("data-threads-docked");
|
|
3659
|
+
}
|
|
2801
3660
|
}
|
|
2802
3661
|
|
|
2803
3662
|
/**
|
|
@@ -2814,6 +3673,17 @@ export class AgUiChat extends HTMLElement {
|
|
|
2814
3673
|
this.#checkpoints.open();
|
|
2815
3674
|
}
|
|
2816
3675
|
|
|
3676
|
+
/**
|
|
3677
|
+
* Close the conversation list, if it is open.
|
|
3678
|
+
*
|
|
3679
|
+
* No state sync here: the drawer reports every close through `onVisibility`,
|
|
3680
|
+
* including the four that never reach this method, and doing it twice would
|
|
3681
|
+
* be a second place to keep right.
|
|
3682
|
+
*/
|
|
3683
|
+
closeThreads(): void {
|
|
3684
|
+
this.#drawer.close();
|
|
3685
|
+
}
|
|
3686
|
+
|
|
2817
3687
|
/** Close the checkpoints panel, if it is open. */
|
|
2818
3688
|
closeCheckpoints(): void {
|
|
2819
3689
|
this.#checkpoints.close();
|
|
@@ -2862,6 +3732,13 @@ export class AgUiChat extends HTMLElement {
|
|
|
2862
3732
|
this.#client = null;
|
|
2863
3733
|
this.#clearTranscript();
|
|
2864
3734
|
this.#initialMessages = [];
|
|
3735
|
+
// The composer's own history goes with the conversation it was typed
|
|
3736
|
+
// into. The path that makes this more than tidiness is the `user-key`
|
|
3737
|
+
// rescope, which purges storage and wipes the transcript precisely so the
|
|
3738
|
+
// previous principal's words are not visible to the next one -- and would
|
|
3739
|
+
// otherwise leave every one of them a single ArrowUp away.
|
|
3740
|
+
this.#sentDrafts.length = 0;
|
|
3741
|
+
this.#recallIndex = null;
|
|
2865
3742
|
}
|
|
2866
3743
|
|
|
2867
3744
|
/**
|
|
@@ -3289,6 +4166,16 @@ export class AgUiChat extends HTMLElement {
|
|
|
3289
4166
|
controls.append(collapse);
|
|
3290
4167
|
header.append(title, headerActions, controls);
|
|
3291
4168
|
|
|
4169
|
+
// A panel is a window and a header is its title bar. Only while open: a
|
|
4170
|
+
// collapsed widget has no header on screen, and the launcher is the handle
|
|
4171
|
+
// then.
|
|
4172
|
+
enablePanelDrag(header, {
|
|
4173
|
+
enabled: () => !this.collapsed && this.#launcherDraggable(),
|
|
4174
|
+
rect: () => this.getBoundingClientRect(),
|
|
4175
|
+
apply: (box, from) => this.#movePanel(box, from),
|
|
4176
|
+
commit: (box, from) => this.#commitPanel(box, from),
|
|
4177
|
+
});
|
|
4178
|
+
|
|
3292
4179
|
this.#messages.className = "messages";
|
|
3293
4180
|
this.#messages.setAttribute("part", "messages");
|
|
3294
4181
|
this.#messages.setAttribute("role", "log");
|
|
@@ -3359,7 +4246,19 @@ export class AgUiChat extends HTMLElement {
|
|
|
3359
4246
|
this.#emptyWrap.setAttribute("part", "empty");
|
|
3360
4247
|
const emptySlot = document.createElement("slot");
|
|
3361
4248
|
emptySlot.name = "empty";
|
|
4249
|
+
// Fallback content, so a host that slots its own gets exactly that and
|
|
4250
|
+
// nothing of ours: the starters live *inside* the slot rather than beside
|
|
4251
|
+
// it, which is the difference between an offer and an imposition.
|
|
4252
|
+
const starters = this.#starterChips();
|
|
4253
|
+
if (starters !== null) {
|
|
4254
|
+
emptySlot.append(starters);
|
|
4255
|
+
}
|
|
3362
4256
|
this.#emptyWrap.append(emptySlot);
|
|
4257
|
+
this.#queuedRow.className = "queued";
|
|
4258
|
+
this.#queuedRow.setAttribute("part", "queued");
|
|
4259
|
+
this.#queuedRow.setAttribute("role", "group");
|
|
4260
|
+
this.#queuedRow.setAttribute("aria-label", this.#strings.queued);
|
|
4261
|
+
this.#queuedRow.hidden = true;
|
|
3363
4262
|
this.#messages.append(this.#emptyWrap);
|
|
3364
4263
|
|
|
3365
4264
|
const inputRow = document.createElement("div");
|
|
@@ -3454,6 +4353,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
3454
4353
|
this.#skillsMenu.palette,
|
|
3455
4354
|
this.#skillsMenu.chips,
|
|
3456
4355
|
this.#skillHint,
|
|
4356
|
+
this.#queuedRow,
|
|
3457
4357
|
this.#attachSlot,
|
|
3458
4358
|
inputRow,
|
|
3459
4359
|
footer,
|
|
@@ -3493,8 +4393,18 @@ export class AgUiChat extends HTMLElement {
|
|
|
3493
4393
|
// decoration to a screen reader rather than a second, context-free number.
|
|
3494
4394
|
this.#badge.setAttribute("aria-hidden", "true");
|
|
3495
4395
|
this.#badge.hidden = true;
|
|
4396
|
+
// Only the edge rail shows this. A full-height column carrying one small
|
|
4397
|
+
// icon reads as a coloured stripe rather than a way back into a
|
|
4398
|
+
// conversation -- it is the widest collapsed state there is and the one
|
|
4399
|
+
// that says least about itself. Written here and hidden in CSS everywhere
|
|
4400
|
+
// else, because the launcher is one element shaped by placement.
|
|
4401
|
+
this.#railLabel.className = "rail-label";
|
|
4402
|
+
this.#railLabel.setAttribute("part", "rail-label");
|
|
4403
|
+
this.#railLabel.setAttribute("aria-hidden", "true");
|
|
4404
|
+
this.#railLabel.textContent = this.getAttribute("title-text") ?? this.#strings.title;
|
|
3496
4405
|
this.#launcher.append(
|
|
3497
4406
|
this.#iconElement("launcher", "launcher-icon", ICON_LAUNCHER, this.#launcherIconUrl()),
|
|
4407
|
+
this.#railLabel,
|
|
3498
4408
|
this.#badge,
|
|
3499
4409
|
);
|
|
3500
4410
|
this.#launcher.addEventListener("click", () => this.setCollapsed(false));
|
|
@@ -3529,7 +4439,9 @@ export class AgUiChat extends HTMLElement {
|
|
|
3529
4439
|
}
|
|
3530
4440
|
this.#focusableGrip();
|
|
3531
4441
|
this.#adoptStyles();
|
|
3532
|
-
this.#
|
|
4442
|
+
this.#viewportProbe.className = "viewport-probe";
|
|
4443
|
+
this.#viewportProbe.setAttribute("aria-hidden", "true");
|
|
4444
|
+
this.#root.append(this.#viewportProbe, this.#announcer, this.#chat, this.#launcher);
|
|
3533
4445
|
}
|
|
3534
4446
|
|
|
3535
4447
|
/**
|
|
@@ -3728,6 +4640,39 @@ export class AgUiChat extends HTMLElement {
|
|
|
3728
4640
|
}
|
|
3729
4641
|
|
|
3730
4642
|
/** Hide the empty-state region once the message list holds anything else. */
|
|
4643
|
+
/**
|
|
4644
|
+
* The prompts offered on an empty transcript, from `data-starters`.
|
|
4645
|
+
*
|
|
4646
|
+
* Different from the suggestion chips a run pushes, which are follow-ups to
|
|
4647
|
+
* something already said. These answer the blank-page question instead, and
|
|
4648
|
+
* they are the host's rather than the model's -- only the host knows what its
|
|
4649
|
+
* page is for. Shares the renderer, the count and the length limit, because
|
|
4650
|
+
* two rows of prompt chips that behaved differently would be the harder
|
|
4651
|
+
* thing to explain.
|
|
4652
|
+
*
|
|
4653
|
+
* Read once at connect: it is content for a state the widget is in before
|
|
4654
|
+
* anything happens, and a host that wants it to change has `slot="empty"`.
|
|
4655
|
+
*/
|
|
4656
|
+
#starterChips(): HTMLElement | null {
|
|
4657
|
+
const raw = this.getAttribute("data-starters");
|
|
4658
|
+
if (raw === null) {
|
|
4659
|
+
return null;
|
|
4660
|
+
}
|
|
4661
|
+
let parsed: unknown;
|
|
4662
|
+
try {
|
|
4663
|
+
parsed = JSON.parse(raw);
|
|
4664
|
+
} catch {
|
|
4665
|
+
console.warn(
|
|
4666
|
+
"<ag-ui-chat>: data-starters is not valid JSON, so no starters are shown. " +
|
|
4667
|
+
"It takes an array of strings, e.g. data-starters='[\"Summarise this page\"]'.",
|
|
4668
|
+
);
|
|
4669
|
+
return null;
|
|
4670
|
+
}
|
|
4671
|
+
return renderSuggestionChips({ prompts: parsed }, this.#strings, (prompt) => {
|
|
4672
|
+
void this.sendMessage(prompt);
|
|
4673
|
+
});
|
|
4674
|
+
}
|
|
4675
|
+
|
|
3731
4676
|
#updateEmptyState(): void {
|
|
3732
4677
|
this.#emptyWrap.hidden = this.#messages.childElementCount > 1;
|
|
3733
4678
|
}
|
|
@@ -3737,6 +4682,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
3737
4682
|
this.#skillsMenu.onInput(this.#input.value);
|
|
3738
4683
|
this.#skillHint.hidden = true;
|
|
3739
4684
|
this.#autoGrow();
|
|
4685
|
+
// Typing puts the composer back in the user's hands: the next ArrowUp
|
|
4686
|
+
// starts from the newest turn again rather than continuing a walk through
|
|
4687
|
+
// history the user has since edited.
|
|
4688
|
+
this.#recallIndex = null;
|
|
3740
4689
|
}
|
|
3741
4690
|
|
|
3742
4691
|
#onKeydown(event: KeyboardEvent): void {
|
|
@@ -3755,7 +4704,51 @@ export class AgUiChat extends HTMLElement {
|
|
|
3755
4704
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
3756
4705
|
event.preventDefault();
|
|
3757
4706
|
void this.#submit();
|
|
4707
|
+
return;
|
|
3758
4708
|
}
|
|
4709
|
+
this.#recallHistory(event);
|
|
4710
|
+
}
|
|
4711
|
+
|
|
4712
|
+
/**
|
|
4713
|
+
* Walk back through what the user has already sent, on the arrow keys.
|
|
4714
|
+
*
|
|
4715
|
+
* Only from an empty composer, and only with the palette closed -- which the
|
|
4716
|
+
* caller has already established, since the palette consumes arrows while it
|
|
4717
|
+
* is open. Both conditions matter: arrows inside text are how you move the
|
|
4718
|
+
* caret, and taking them would break editing to add a shortcut.
|
|
4719
|
+
*
|
|
4720
|
+
* The drafts are the user's own turns in this conversation, newest first,
|
|
4721
|
+
* which is what every shell and every coding agent means by this. Arrowing
|
|
4722
|
+
* forward past the newest empties the composer again rather than sticking on
|
|
4723
|
+
* it, so the way out is the same key that got you in.
|
|
4724
|
+
*/
|
|
4725
|
+
#recallHistory(event: KeyboardEvent): void {
|
|
4726
|
+
const back = event.key === "ArrowUp";
|
|
4727
|
+
if ((!back && event.key !== "ArrowDown") || this.#skillsMenu.isOpen()) {
|
|
4728
|
+
return;
|
|
4729
|
+
}
|
|
4730
|
+
const drafts = this.#sentDrafts;
|
|
4731
|
+
if (drafts.length === 0) {
|
|
4732
|
+
return;
|
|
4733
|
+
}
|
|
4734
|
+
// An empty composer is the only safe entry: anything typed is the user's,
|
|
4735
|
+
// and replacing it with a past turn would lose it without asking.
|
|
4736
|
+
if (this.#recallIndex === null && (!back || this.#input.value !== "")) {
|
|
4737
|
+
return;
|
|
4738
|
+
}
|
|
4739
|
+
const next = this.#recallIndex === null ? 0 : this.#recallIndex + (back ? 1 : -1);
|
|
4740
|
+
if (next >= drafts.length) {
|
|
4741
|
+
return;
|
|
4742
|
+
}
|
|
4743
|
+
event.preventDefault();
|
|
4744
|
+
this.#recallIndex = next < 0 ? null : next;
|
|
4745
|
+
// Asserted rather than defaulted: `next` was bounded on both sides two
|
|
4746
|
+
// lines up, so a fallback here would be a branch no test can reach
|
|
4747
|
+
// honestly -- and an unreachable default is worse than an assertion,
|
|
4748
|
+
// because it looks like a case somebody thought about.
|
|
4749
|
+
this.#input.value = next < 0 ? "" : (drafts[next] as string);
|
|
4750
|
+
this.#input.setSelectionRange(this.#input.value.length, this.#input.value.length);
|
|
4751
|
+
this.#autoGrow();
|
|
3759
4752
|
}
|
|
3760
4753
|
|
|
3761
4754
|
/**
|
|
@@ -3765,6 +4758,26 @@ export class AgUiChat extends HTMLElement {
|
|
|
3765
4758
|
* observes the disconnect).
|
|
3766
4759
|
*/
|
|
3767
4760
|
#cancelRun(): void {
|
|
4761
|
+
// Stopping discards what was waiting. Sending messages into a conversation
|
|
4762
|
+
// the user has just stopped is the opposite of what stopping meant, and it
|
|
4763
|
+
// would arrive after they had already turned away.
|
|
4764
|
+
//
|
|
4765
|
+
// Not sending it is not the same as destroying it, though. A queued
|
|
4766
|
+
// message left the composer the moment it was queued, so dropping it here
|
|
4767
|
+
// would take a paragraph the user typed and leave it nowhere -- not on
|
|
4768
|
+
// screen, not in the composer, not recallable. It goes to the front of the
|
|
4769
|
+
// recall history instead, so ArrowUp gets it back. In queue order, which
|
|
4770
|
+
// puts the one typed last first.
|
|
4771
|
+
//
|
|
4772
|
+
// This path is also reached from `disconnectedCallback`, where a DOM move
|
|
4773
|
+
// and a framework re-render both look like a farewell and neither is one.
|
|
4774
|
+
for (const text of this.#queued) {
|
|
4775
|
+
if (this.#sentDrafts[0] !== text) {
|
|
4776
|
+
this.#sentDrafts.unshift(text);
|
|
4777
|
+
}
|
|
4778
|
+
}
|
|
4779
|
+
this.#queued.length = 0;
|
|
4780
|
+
this.#renderQueued();
|
|
3768
4781
|
this.#confirmAbort?.abort();
|
|
3769
4782
|
this.#client?.cancel();
|
|
3770
4783
|
}
|
|
@@ -3777,11 +4790,56 @@ export class AgUiChat extends HTMLElement {
|
|
|
3777
4790
|
* that it carries no text.
|
|
3778
4791
|
*/
|
|
3779
4792
|
#setRunning(running: boolean): void {
|
|
4793
|
+
const settled = this.#running && !running;
|
|
3780
4794
|
this.#running = running;
|
|
3781
4795
|
const label = running ? this.#strings.stop : this.#strings.send;
|
|
3782
4796
|
this.#send.title = label;
|
|
3783
4797
|
this.#send.setAttribute("aria-label", label);
|
|
3784
4798
|
this.#send.dataset["state"] = running ? "running" : "idle";
|
|
4799
|
+
if (settled) {
|
|
4800
|
+
this.#flushQueued();
|
|
4801
|
+
}
|
|
4802
|
+
}
|
|
4803
|
+
|
|
4804
|
+
/**
|
|
4805
|
+
* Send the next message that was typed while the run was going.
|
|
4806
|
+
*
|
|
4807
|
+
* One at a time, through the same path as anything else: each queued turn
|
|
4808
|
+
* starts a run of its own, and the next is sent when *that* one settles. Any
|
|
4809
|
+
* other shape would be a second sender racing the guard above.
|
|
4810
|
+
*/
|
|
4811
|
+
#flushQueued(): void {
|
|
4812
|
+
const next = this.#queued.shift();
|
|
4813
|
+
this.#renderQueued();
|
|
4814
|
+
if (next !== undefined) {
|
|
4815
|
+
void this.sendMessage(next);
|
|
4816
|
+
}
|
|
4817
|
+
}
|
|
4818
|
+
|
|
4819
|
+
/**
|
|
4820
|
+
* Draw what is waiting, as chips that can be taken back.
|
|
4821
|
+
*
|
|
4822
|
+
* Visible and removable, because a message the user typed and cannot see is
|
|
4823
|
+
* a message they will type again -- and one they changed their mind about
|
|
4824
|
+
* has to be retractable before it is sent on their behalf.
|
|
4825
|
+
*/
|
|
4826
|
+
#renderQueued(): void {
|
|
4827
|
+
this.#queuedRow.replaceChildren();
|
|
4828
|
+
this.#queuedRow.hidden = this.#queued.length === 0;
|
|
4829
|
+
for (const [index, text] of this.#queued.entries()) {
|
|
4830
|
+
const chip = document.createElement("button");
|
|
4831
|
+
chip.type = "button";
|
|
4832
|
+
chip.className = "queued-chip";
|
|
4833
|
+
chip.setAttribute("part", "queued-chip");
|
|
4834
|
+
chip.textContent = text;
|
|
4835
|
+
chip.title = this.#strings.removeQueued.replace("{text}", text);
|
|
4836
|
+
chip.setAttribute("aria-label", chip.title);
|
|
4837
|
+
chip.addEventListener("click", () => {
|
|
4838
|
+
this.#queued.splice(index, 1);
|
|
4839
|
+
this.#renderQueued();
|
|
4840
|
+
});
|
|
4841
|
+
this.#queuedRow.appendChild(chip);
|
|
4842
|
+
}
|
|
3785
4843
|
}
|
|
3786
4844
|
|
|
3787
4845
|
/**
|
|
@@ -3803,15 +4861,37 @@ export class AgUiChat extends HTMLElement {
|
|
|
3803
4861
|
// Enter has no such guard; without this it would start a second concurrent
|
|
3804
4862
|
// SSE run that orphans the first (unabortable) and lets the second run's
|
|
3805
4863
|
// settle sweep corrupt the first's still-pending tool cards.
|
|
3806
|
-
if (this.#running) {
|
|
3807
|
-
return;
|
|
3808
|
-
}
|
|
3809
4864
|
const content = this.#input.value.trim();
|
|
3810
4865
|
const attachments = this.#attachTray?.readyRefs() ?? [];
|
|
3811
4866
|
// Allow an attachments-only message (no typed text), but nothing empty.
|
|
3812
4867
|
if (content === "" && attachments.length === 0) {
|
|
3813
4868
|
return;
|
|
3814
4869
|
}
|
|
4870
|
+
// A second run cannot start while one is in flight: it would orphan the
|
|
4871
|
+
// first, which is unabortable, and the second's settle sweep would corrupt
|
|
4872
|
+
// the first's still-pending tool cards. That is why this was a dead key --
|
|
4873
|
+
// Enter during a run did nothing at all, silently.
|
|
4874
|
+
//
|
|
4875
|
+
// Queueing keeps the guard and gives the key something to do. Text only:
|
|
4876
|
+
// an attachment is settled state the tray is holding and the composer has
|
|
4877
|
+
// no second copy of, so parking it here would mean deciding what happens
|
|
4878
|
+
// when the user then removes the chip.
|
|
4879
|
+
if (this.#running) {
|
|
4880
|
+
if (content !== "") {
|
|
4881
|
+
this.#queued.push(content);
|
|
4882
|
+
this.#renderQueued();
|
|
4883
|
+
this.#input.value = "";
|
|
4884
|
+
this.#autoGrow();
|
|
4885
|
+
}
|
|
4886
|
+
return;
|
|
4887
|
+
}
|
|
4888
|
+
// Recorded before the box is cleared, newest first, so the arrow keys walk
|
|
4889
|
+
// back through it. A repeat of the last one is not a second entry: the
|
|
4890
|
+
// point is to reach what was said, not how often.
|
|
4891
|
+
if (content !== "" && this.#sentDrafts[0] !== content) {
|
|
4892
|
+
this.#sentDrafts.unshift(content);
|
|
4893
|
+
}
|
|
4894
|
+
this.#recallIndex = null;
|
|
3815
4895
|
this.#input.value = "";
|
|
3816
4896
|
this.#autoGrow();
|
|
3817
4897
|
// A file still uploading does not ride along — `readyRefs()` returns only
|
|
@@ -4844,12 +5924,40 @@ export class AgUiChat extends HTMLElement {
|
|
|
4844
5924
|
return true;
|
|
4845
5925
|
}
|
|
4846
5926
|
|
|
4847
|
-
|
|
4848
|
-
|
|
5927
|
+
/**
|
|
5928
|
+
* An inline notice about something the run did.
|
|
5929
|
+
*
|
|
5930
|
+
* `undo` is offered only where the agent rearranged the user's own window --
|
|
5931
|
+
* see {@link renderRunNotice} for why a notice may carry that one control and
|
|
5932
|
+
* nothing else.
|
|
5933
|
+
*/
|
|
5934
|
+
#appendNotice(
|
|
5935
|
+
icon: string,
|
|
5936
|
+
text: string,
|
|
5937
|
+
kind: string,
|
|
5938
|
+
undo?: { readonly label: string; readonly onActivate: () => void },
|
|
5939
|
+
): void {
|
|
5940
|
+
this.#ensureGroup().appendChild(renderRunNotice(icon, text, kind, undo));
|
|
4849
5941
|
this.#updateEmptyState();
|
|
4850
5942
|
this.#scroller.follow();
|
|
4851
5943
|
}
|
|
4852
5944
|
|
|
5945
|
+
/**
|
|
5946
|
+
* Say that the agent rearranged the user's window, and offer the way back.
|
|
5947
|
+
*
|
|
5948
|
+
* Only on the agent's path. A host calling {@link moveTo} is arranging its
|
|
5949
|
+
* own page and does not need telling what it just did; an agent doing it
|
|
5950
|
+
* mid-conversation is the case where a panel appears to move on its own.
|
|
5951
|
+
*/
|
|
5952
|
+
#announceSurfaceChange(text: string, undo: (() => void) | null): void {
|
|
5953
|
+
this.#appendNotice(
|
|
5954
|
+
"⤢",
|
|
5955
|
+
text,
|
|
5956
|
+
"surface",
|
|
5957
|
+
undo === null ? undefined : { label: this.#strings.undo, onActivate: undo },
|
|
5958
|
+
);
|
|
5959
|
+
}
|
|
5960
|
+
|
|
4853
5961
|
/**
|
|
4854
5962
|
* Turn on chart rendering, by whichever route this consumer wants.
|
|
4855
5963
|
*
|