@artooi/ag-ui-web-component 0.31.1 → 0.33.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 +197 -1
- package/README.md +182 -23
- package/dist/ag-ui-web-component.bundle.js +352 -128
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +34 -7
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +25 -0
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/index.js +1367 -388
- package/dist/index.js.map +4 -4
- package/dist/ui/clamp_launcher.d.ts +15 -0
- package/dist/ui/clamp_launcher.d.ts.map +1 -0
- package/dist/ui/copy_payload.d.ts +30 -0
- package/dist/ui/copy_payload.d.ts.map +1 -0
- package/dist/ui/launcher_drag.d.ts +43 -0
- package/dist/ui/launcher_drag.d.ts.map +1 -0
- package/dist/ui/launcher_placement.d.ts +64 -0
- package/dist/ui/launcher_placement.d.ts.map +1 -0
- package/dist/ui/message_actions.d.ts +30 -2
- package/dist/ui/message_actions.d.ts.map +1 -1
- package/dist/ui/resize_handle.d.ts +32 -26
- package/dist/ui/resize_handle.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/subagent_panel.d.ts +9 -0
- package/dist/ui/subagent_panel.d.ts.map +1 -1
- package/dist/ui/subagent_update.d.ts +9 -0
- package/dist/ui/subagent_update.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +19 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/constants.ts +38 -7
- package/src/core/ag_ui_chat.ts +570 -24
- package/src/core/agui_client.ts +41 -0
- package/src/ui/clamp_launcher.ts +20 -0
- package/src/ui/copy_payload.ts +156 -0
- package/src/ui/launcher_drag.ts +182 -0
- package/src/ui/launcher_placement.ts +143 -0
- package/src/ui/message_actions.ts +93 -19
- package/src/ui/resize_handle.ts +109 -73
- package/src/ui/styles.ts +283 -81
- package/src/ui/subagent_panel.ts +14 -0
- package/src/ui/subagent_update.ts +9 -0
- package/src/ui/ui_strings.ts +22 -0
- package/src/version.ts +1 -1
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
FEEDBACK_EVENT,
|
|
11
11
|
ICON_ATTACH,
|
|
12
12
|
ICON_LAUNCHER,
|
|
13
|
+
ICON_RETRY,
|
|
13
14
|
ICON_SEND,
|
|
14
15
|
ICON_STOP,
|
|
15
16
|
INVALIDATE_CUSTOM_NAME,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
RUN_FINISHED_EVENT,
|
|
23
24
|
STATE_EVENT,
|
|
24
25
|
SUBAGENT_CUSTOM_NAME,
|
|
26
|
+
SUBAGENT_PHASE,
|
|
25
27
|
SUBMIT_EVENT,
|
|
26
28
|
SUGGESTIONS_ACTIVITY_TYPE,
|
|
27
29
|
TOGGLE_EVENT,
|
|
@@ -55,7 +57,16 @@ import { renderChart } from "../ui/chart_block.js";
|
|
|
55
57
|
import { chartSpecFrom } from "../ui/chart_spec_from.js";
|
|
56
58
|
import { CHART_TOOL_NAME, createChartTool } from "../ui/chart_tool.js";
|
|
57
59
|
import { CheckpointMenu, type CheckpointVerb } from "../ui/checkpoint_menu.js";
|
|
60
|
+
import { clampLauncher } from "../ui/clamp_launcher.js";
|
|
58
61
|
import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
|
|
62
|
+
import { copyPayload } from "../ui/copy_payload.js";
|
|
63
|
+
import { enableLauncherDrag } from "../ui/launcher_drag.js";
|
|
64
|
+
import {
|
|
65
|
+
type ExpandCorner,
|
|
66
|
+
type Extent,
|
|
67
|
+
type LauncherBox,
|
|
68
|
+
launcherPlacement,
|
|
69
|
+
} from "../ui/launcher_placement.js";
|
|
59
70
|
import {
|
|
60
71
|
attachMessageActions,
|
|
61
72
|
messageActionBar,
|
|
@@ -73,8 +84,11 @@ import type { RelativeTimeFormatter } from "../ui/relative_time.js";
|
|
|
73
84
|
import { renderMarkdown } from "../ui/render_markdown.js";
|
|
74
85
|
import {
|
|
75
86
|
createResizeHandle,
|
|
87
|
+
gripName,
|
|
88
|
+
type PanelRect,
|
|
76
89
|
type ResizeAnchor,
|
|
77
90
|
type ResizeAxis,
|
|
91
|
+
type ResizeGrip,
|
|
78
92
|
type ResizeSize,
|
|
79
93
|
} from "../ui/resize_handle.js";
|
|
80
94
|
import { wrapWords } from "../ui/reveal_words.js";
|
|
@@ -82,7 +96,7 @@ import { renderRunNotice } from "../ui/run_notice.js";
|
|
|
82
96
|
import { SkillsMenu } from "../ui/skills_menu.js";
|
|
83
97
|
import { createStickToBottom, type StickToBottom } from "../ui/stick_to_bottom.js";
|
|
84
98
|
import { STYLES } from "../ui/styles.js";
|
|
85
|
-
import { SubAgentPanel } from "../ui/subagent_panel.js";
|
|
99
|
+
import { SubAgentPanel, type SubAgentPhase, type SubAgentUpdate } from "../ui/subagent_panel.js";
|
|
86
100
|
import { subAgentUpdate } from "../ui/subagent_update.js";
|
|
87
101
|
import { renderSuggestionChips } from "../ui/suggestion_chips.js";
|
|
88
102
|
import { ThoughtsBlock } from "../ui/thoughts_block.js";
|
|
@@ -299,6 +313,32 @@ const SIZE_KEY = "ag-ui-chat:size";
|
|
|
299
313
|
/** Per-tab persistence key for the built-in theme toggle. */
|
|
300
314
|
const THEME_KEY = "ag-ui-chat:theme";
|
|
301
315
|
|
|
316
|
+
/** Per-tab persistence key for a dragged launcher position. */
|
|
317
|
+
const LAUNCHER_KEY = "ag-ui-chat:launcher";
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Placements whose launcher can be dragged. The rest have nowhere to put it:
|
|
321
|
+
* a sidebar collapses to a full-height edge rail, "embedded" and "page" hide
|
|
322
|
+
* the launcher entirely and keep their header bar, and a full-bleed panel
|
|
323
|
+
* covers the screen it would be opening into.
|
|
324
|
+
*/
|
|
325
|
+
const DRAGGABLE_PLACEMENTS = new Set([null, "", "floating", "bottom-left"]);
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Every edge and corner the panel can be dragged by. Corners last, so they sit
|
|
329
|
+
* above the edge strips they overlap and win the pointer at the corners.
|
|
330
|
+
*/
|
|
331
|
+
const RESIZE_GRIPS: readonly ResizeGrip[] = [
|
|
332
|
+
{ y: "top" },
|
|
333
|
+
{ y: "bottom" },
|
|
334
|
+
{ x: "left" },
|
|
335
|
+
{ x: "right" },
|
|
336
|
+
{ x: "left", y: "top" },
|
|
337
|
+
{ x: "right", y: "top" },
|
|
338
|
+
{ x: "left", y: "bottom" },
|
|
339
|
+
{ x: "right", y: "bottom" },
|
|
340
|
+
];
|
|
341
|
+
|
|
302
342
|
/** Pixels between a selection and the offer to quote it. */
|
|
303
343
|
const QUOTE_GAP = 6;
|
|
304
344
|
|
|
@@ -630,6 +670,17 @@ export class AgUiChat extends HTMLElement {
|
|
|
630
670
|
* approval prompt already uses.
|
|
631
671
|
*/
|
|
632
672
|
readonly #subagentPanels = new Map<string, SubAgentPanel>();
|
|
673
|
+
/**
|
|
674
|
+
* Which delegation each live `subagentRunId` belongs to.
|
|
675
|
+
*
|
|
676
|
+
* The protocol's closing events -- `SUBAGENT_FINISHED` and `SUBAGENT_ERROR`
|
|
677
|
+
* -- carry the child's run id and nothing else, while everything drawn here
|
|
678
|
+
* is keyed on the parent's `delegate_task` call id. `SUBAGENT_STARTED` is the
|
|
679
|
+
* one event carrying both, so the pairing is recorded there and read back on
|
|
680
|
+
* the close. A close naming a run this never saw open is dropped, which is
|
|
681
|
+
* the same refusal a step for an undrawn card gets.
|
|
682
|
+
*/
|
|
683
|
+
readonly #subagentRunDelegations = new Map<string, string>();
|
|
633
684
|
/**
|
|
634
685
|
* Call ids whose card was already settled from a streamed server-side result
|
|
635
686
|
* (`TOOL_CALL_RESULT`), so the post-run executeTool sweep doesn't overwrite
|
|
@@ -759,6 +810,37 @@ export class AgUiChat extends HTMLElement {
|
|
|
759
810
|
readonly #emptyWrap: HTMLDivElement;
|
|
760
811
|
/** Upload tray; created on connect only when `data-attachments-url` is set. */
|
|
761
812
|
#attachTray: AttachmentTray | null = null;
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Where the user dragged the launcher, in viewport coordinates, or null
|
|
816
|
+
* while the host's own CSS still places it. Set means this element owns its
|
|
817
|
+
* position -- see #applyLauncherPlacement for what that costs the host.
|
|
818
|
+
*/
|
|
819
|
+
#launcherPos: { readonly left: number; readonly top: number } | null = null;
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* The corner the panel opens away from, once this element is placing itself.
|
|
823
|
+
* Null means the host's layout still decides, and the anchor is measured.
|
|
824
|
+
*/
|
|
825
|
+
#expandCorner: ExpandCorner | null = null;
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* The edges the layout is holding still, as last measured. Cached because a
|
|
829
|
+
* resize reads it per pointer move and measuring forces a reflow -- thirty a
|
|
830
|
+
* second while the panel is already being laid out on every one of them.
|
|
831
|
+
*/
|
|
832
|
+
#anchor: ResizeAnchor = { x: "right", y: "bottom" };
|
|
833
|
+
|
|
834
|
+
/** The eight grips, by name, so the keyboard-reachable one can be moved. */
|
|
835
|
+
readonly #resizeHandles = new Map<string, HTMLDivElement>();
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Re-clamp the dragged launcher when the window changes size. Bound once as
|
|
839
|
+
* a field so `removeEventListener` on disconnect gets the same reference.
|
|
840
|
+
*/
|
|
841
|
+
readonly #onViewportResize = (): void => {
|
|
842
|
+
this.#restoreLauncherPosition();
|
|
843
|
+
};
|
|
762
844
|
/** Mic button mount point (input row); the control mounts on connect when enabled. */
|
|
763
845
|
readonly #voiceSlot: HTMLSpanElement;
|
|
764
846
|
/** Voice-input control; created on connect when transcription is available. */
|
|
@@ -995,6 +1077,9 @@ export class AgUiChat extends HTMLElement {
|
|
|
995
1077
|
// else: a size dragged under the previous placement would otherwise sit
|
|
996
1078
|
// inline and outrank the new one.
|
|
997
1079
|
this.#releaseOwnedAxes();
|
|
1080
|
+
// Position is owned the same way a size is: a placement that places
|
|
1081
|
+
// itself takes back a launcher the user had dragged somewhere else.
|
|
1082
|
+
this.#releaseLauncherPosition();
|
|
998
1083
|
// Placement also moves the panel, so the edges its layout holds still
|
|
999
1084
|
// change with it. Deferred a frame so the new rules have applied.
|
|
1000
1085
|
requestAnimationFrame(() => this.#syncResizeAnchor());
|
|
@@ -1421,7 +1506,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
1421
1506
|
// frame so the host's own stylesheet has applied; re-measured on every drag
|
|
1422
1507
|
// anyway, so a wrong first guess costs a grip in the wrong corner and never
|
|
1423
1508
|
// a wrong resize.
|
|
1424
|
-
requestAnimationFrame(() =>
|
|
1509
|
+
requestAnimationFrame(() => {
|
|
1510
|
+
// Before the anchor, which #applyLauncherPlacement stamps itself once a
|
|
1511
|
+
// dragged position exists.
|
|
1512
|
+
this.#restoreLauncherPosition();
|
|
1513
|
+
this.#syncResizeAnchor();
|
|
1514
|
+
});
|
|
1425
1515
|
// Resolve the string table before rendering any chrome (defaults are the
|
|
1426
1516
|
// floor; `data-strings` then the `strings` property layer over them).
|
|
1427
1517
|
this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
|
|
@@ -1451,6 +1541,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1451
1541
|
namespace === "" ? this.conversationStore : new SessionStorageStore(namespace);
|
|
1452
1542
|
this.conversationStore = this.#builtinStore;
|
|
1453
1543
|
}
|
|
1544
|
+
window.addEventListener("resize", this.#onViewportResize);
|
|
1454
1545
|
this.#wireThreadStore();
|
|
1455
1546
|
this.#wireAttachments();
|
|
1456
1547
|
this.#wireVoice();
|
|
@@ -1523,6 +1614,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1523
1614
|
*/
|
|
1524
1615
|
disconnectedCallback(): void {
|
|
1525
1616
|
this.#connected = false;
|
|
1617
|
+
window.removeEventListener("resize", this.#onViewportResize);
|
|
1526
1618
|
// Give the namespace back. A disconnect is not necessarily a farewell — a
|
|
1527
1619
|
// DOM move and a framework re-render both look like one — and an element
|
|
1528
1620
|
// that could not reclaim its own namespace on the way back in would lose
|
|
@@ -1607,6 +1699,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1607
1699
|
this.#fileInput.accept = accept;
|
|
1608
1700
|
this.#attachButton.hidden = false;
|
|
1609
1701
|
this.#enableDragAndDrop();
|
|
1702
|
+
this.#enablePaste();
|
|
1610
1703
|
}
|
|
1611
1704
|
|
|
1612
1705
|
/** The built-in multipart upload handler for `data-attachments-url`, or `null`. */
|
|
@@ -1874,6 +1967,41 @@ export class AgUiChat extends HTMLElement {
|
|
|
1874
1967
|
});
|
|
1875
1968
|
}
|
|
1876
1969
|
|
|
1970
|
+
/**
|
|
1971
|
+
* Accept files pasted into the composer.
|
|
1972
|
+
*
|
|
1973
|
+
* The whole tray already exists behind this: a paste is one more way to hand
|
|
1974
|
+
* it a `File`, alongside the picker and a drop.
|
|
1975
|
+
*
|
|
1976
|
+
* Two rules keep it from stealing a paste that was never about files.
|
|
1977
|
+
* `clipboardData.files` is empty for text, so ordinary pasting is untouched.
|
|
1978
|
+
* And the default is only prevented when the clipboard carries **no text**:
|
|
1979
|
+
* copying a rich selection that happens to contain an image puts both on the
|
|
1980
|
+
* clipboard, and swallowing the words someone meant to paste in order to
|
|
1981
|
+
* attach a picture they did not is the worse of the two failures.
|
|
1982
|
+
*/
|
|
1983
|
+
#enablePaste(): void {
|
|
1984
|
+
this.#chat.addEventListener("paste", (event: ClipboardEvent) => {
|
|
1985
|
+
// Nullish rather than a null check: the property is typed as nullable,
|
|
1986
|
+
// and an engine that fires a plain Event for a paste leaves it absent
|
|
1987
|
+
// instead, which is not the same value and is the same situation.
|
|
1988
|
+
const clipboard = event.clipboardData ?? null;
|
|
1989
|
+
if (clipboard === null) {
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
const files = Array.from(clipboard.files);
|
|
1993
|
+
if (files.length === 0) {
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
if (clipboard.getData("text/plain") === "") {
|
|
1997
|
+
event.preventDefault();
|
|
1998
|
+
}
|
|
1999
|
+
for (const file of files) {
|
|
2000
|
+
this.#attachTray?.add(named(file));
|
|
2001
|
+
}
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1877
2005
|
/**
|
|
1878
2006
|
* When `data-threads-url` is set, route thread enumeration / load / rename /
|
|
1879
2007
|
* delete through that server endpoint (wrapping the current store as the
|
|
@@ -2049,6 +2177,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
2049
2177
|
* its own chrome.
|
|
2050
2178
|
*/
|
|
2051
2179
|
setCollapsed(collapsed: boolean): void {
|
|
2180
|
+
if (!collapsed) {
|
|
2181
|
+
// Re-decide which way to open before opening: the viewport may have
|
|
2182
|
+
// changed since the launcher was dropped, and this corner is what the
|
|
2183
|
+
// panel grows from.
|
|
2184
|
+
this.#restoreLauncherPosition();
|
|
2185
|
+
}
|
|
2052
2186
|
if (collapsed) {
|
|
2053
2187
|
this.setAttribute("collapsed", "");
|
|
2054
2188
|
} else {
|
|
@@ -2128,15 +2262,57 @@ export class AgUiChat extends HTMLElement {
|
|
|
2128
2262
|
const before = this.getBoundingClientRect();
|
|
2129
2263
|
const width = this.style.getPropertyValue("--ag-ui-width");
|
|
2130
2264
|
const height = this.style.getPropertyValue("--ag-ui-height");
|
|
2131
|
-
|
|
2132
|
-
|
|
2265
|
+
// Shrink first. Growing is the obvious probe and cannot answer the question
|
|
2266
|
+
// at a size already resting against max-width or max-height: the box does
|
|
2267
|
+
// not change, no edge moves, and every clamped axis then reads as pinned on
|
|
2268
|
+
// the side that did not move -- which is the side that is free. That is not
|
|
2269
|
+
// an edge case. The default panel is 380px wide against a max-width of
|
|
2270
|
+
// 100vw minus 48, so any viewport under 428px is born clamped, and the grip
|
|
2271
|
+
// rendered on the wrong corner with the drag inverted before anyone touched
|
|
2272
|
+
// it. Shrinking always moves an edge, because the shrink is measured from
|
|
2273
|
+
// the box's *used* width rather than from whatever was asked for.
|
|
2274
|
+
const shrunk = this.#probeAnchor(before, -1);
|
|
2275
|
+
// Unless a host rule sets a minimum, in which case that axis is asked the
|
|
2276
|
+
// opposite question rather than left to a guess.
|
|
2277
|
+
const grown = shrunk.x === null || shrunk.y === null ? this.#probeAnchor(before, 1) : shrunk;
|
|
2133
2278
|
// Restore exactly what was there, including "nothing" — leaving a probe
|
|
2134
2279
|
// value behind would pin a panel that had been sizing itself.
|
|
2135
2280
|
this.#restoreProperty("--ag-ui-width", width);
|
|
2136
2281
|
this.#restoreProperty("--ag-ui-height", height);
|
|
2137
2282
|
return {
|
|
2138
|
-
|
|
2139
|
-
|
|
2283
|
+
// Neither direction moved it: the axis cannot be resized at all, so the
|
|
2284
|
+
// floating default is the best answer available and is the one the
|
|
2285
|
+
// stylesheet would have used with no measurement at all.
|
|
2286
|
+
x: shrunk.x ?? grown.x ?? "right",
|
|
2287
|
+
y: shrunk.y ?? grown.y ?? "bottom",
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
/**
|
|
2292
|
+
* Which edge each axis holds still when the panel changes size by `delta`.
|
|
2293
|
+
*
|
|
2294
|
+
* Null for an axis whose size did not change: nothing moved, so nothing was
|
|
2295
|
+
* learned, and reporting the unmoved edge as the pinned one would be exactly
|
|
2296
|
+
* backwards.
|
|
2297
|
+
*/
|
|
2298
|
+
#probeAnchor(
|
|
2299
|
+
before: DOMRect,
|
|
2300
|
+
delta: number,
|
|
2301
|
+
): { x: "left" | "right" | null; y: "top" | "bottom" | null } {
|
|
2302
|
+
this.#applySize({ width: before.width + delta, height: before.height + delta });
|
|
2303
|
+
const after = this.getBoundingClientRect();
|
|
2304
|
+
const moved = (a: number, b: number): boolean => Math.abs(a - b) >= 0.5;
|
|
2305
|
+
return {
|
|
2306
|
+
x: moved(after.width, before.width)
|
|
2307
|
+
? moved(after.left, before.left)
|
|
2308
|
+
? "right"
|
|
2309
|
+
: "left"
|
|
2310
|
+
: null,
|
|
2311
|
+
y: moved(after.height, before.height)
|
|
2312
|
+
? moved(after.top, before.top)
|
|
2313
|
+
? "bottom"
|
|
2314
|
+
: "top"
|
|
2315
|
+
: null,
|
|
2140
2316
|
};
|
|
2141
2317
|
}
|
|
2142
2318
|
|
|
@@ -2145,8 +2321,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
2145
2321
|
if (!this.#connected) {
|
|
2146
2322
|
return;
|
|
2147
2323
|
}
|
|
2148
|
-
|
|
2324
|
+
// When this element owns its position it knows which edges are pinned --
|
|
2325
|
+
// it just wrote them -- so there is nothing to probe. The probe is also
|
|
2326
|
+
// unreliable at a size resting against max-width or max-height, where a
|
|
2327
|
+
// nudge moves no edge and every axis reads as pinned on the wrong side.
|
|
2328
|
+
const anchor = this.#expandCorner ?? this.#measureAnchor();
|
|
2329
|
+
this.#anchor = anchor;
|
|
2149
2330
|
this.setAttribute("data-resize-anchor", `${anchor.y}-${anchor.x}`);
|
|
2331
|
+
this.#focusableGrip();
|
|
2150
2332
|
}
|
|
2151
2333
|
|
|
2152
2334
|
/** Put a custom property back to a previous value, or remove it if there was none. */
|
|
@@ -2199,6 +2381,236 @@ export class AgUiChat extends HTMLElement {
|
|
|
2199
2381
|
}
|
|
2200
2382
|
}
|
|
2201
2383
|
|
|
2384
|
+
/**
|
|
2385
|
+
* Whether this placement lets the launcher be dragged, read per interaction
|
|
2386
|
+
* because `placement` is a live attribute. `data-launcher-drag="false"` opts
|
|
2387
|
+
* a host out without giving up the launcher itself.
|
|
2388
|
+
*/
|
|
2389
|
+
#launcherDraggable(): boolean {
|
|
2390
|
+
return (
|
|
2391
|
+
this.getAttribute("data-launcher-drag") !== "false" &&
|
|
2392
|
+
DRAGGABLE_PLACEMENTS.has(this.getAttribute("placement"))
|
|
2393
|
+
);
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
/** The viewport the launcher and the panel both have to fit inside. */
|
|
2397
|
+
#viewport(): Extent {
|
|
2398
|
+
return { width: window.innerWidth, height: window.innerHeight };
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
/**
|
|
2402
|
+
* The launcher's box in viewport coordinates, with its transform divided out.
|
|
2403
|
+
*
|
|
2404
|
+
* The launcher is scaled in four states -- the collapse animation, hover,
|
|
2405
|
+
* press, and the resting scale(0.4) it sits at while the panel is open -- and
|
|
2406
|
+
* `getBoundingClientRect` reports every one of them. A drag that started from
|
|
2407
|
+
* that rect would begin a couple of pixels off, because a press is one of
|
|
2408
|
+
* those states.
|
|
2409
|
+
*
|
|
2410
|
+
* So the size comes from `offsetWidth`/`offsetHeight`, which are layout
|
|
2411
|
+
* metrics no transform reaches, and the position from the rect's *centre*,
|
|
2412
|
+
* which a centred scale is the one point that cannot move.
|
|
2413
|
+
*/
|
|
2414
|
+
#launcherBox(): LauncherBox {
|
|
2415
|
+
const width = this.#launcher.offsetWidth;
|
|
2416
|
+
const height = this.#launcher.offsetHeight;
|
|
2417
|
+
const dragged = this.#launcherPos;
|
|
2418
|
+
if (dragged !== null) {
|
|
2419
|
+
return { left: dragged.left, top: dragged.top, width, height };
|
|
2420
|
+
}
|
|
2421
|
+
const rect = this.#launcher.getBoundingClientRect();
|
|
2422
|
+
return {
|
|
2423
|
+
left: rect.left + rect.width / 2 - width / 2,
|
|
2424
|
+
top: rect.top + rect.height / 2 - height / 2,
|
|
2425
|
+
width,
|
|
2426
|
+
height,
|
|
2427
|
+
};
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
/**
|
|
2431
|
+
* Place the host box and the launcher for the position the user dragged to.
|
|
2432
|
+
*
|
|
2433
|
+
* This writes `--ag-ui-inset`, which is a host-facing property: an inline
|
|
2434
|
+
* value outranks the page's own rule for it, exactly as a dragged width
|
|
2435
|
+
* outranks a placement's. That is the intent -- the user moved it -- and it
|
|
2436
|
+
* is why switching to a placement that owns its position hands the property
|
|
2437
|
+
* back rather than leaving a stale inline one behind.
|
|
2438
|
+
*/
|
|
2439
|
+
#applyLauncherPlacement(at: { readonly left: number; readonly top: number }): void {
|
|
2440
|
+
// The single gate: callers hand over a position and this decides whether
|
|
2441
|
+
// it is this element's to honour. Checking in both places instead would
|
|
2442
|
+
// leave one of the two checks permanently unreachable.
|
|
2443
|
+
if (!this.#launcherDraggable()) {
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
this.#launcherPos = at;
|
|
2447
|
+
// The host box keeps its expanded size while collapsed, so its own rect is
|
|
2448
|
+
// the panel's size in either state and needs no separate bookkeeping.
|
|
2449
|
+
const panel = this.getBoundingClientRect();
|
|
2450
|
+
const placement = launcherPlacement(
|
|
2451
|
+
this.#launcherBox(),
|
|
2452
|
+
{ width: panel.width, height: panel.height },
|
|
2453
|
+
this.#viewport(),
|
|
2454
|
+
);
|
|
2455
|
+
this.style.setProperty("--ag-ui-inset", placement.hostInset);
|
|
2456
|
+
this.style.setProperty("--ag-ui-launcher-inset", placement.launcherInset);
|
|
2457
|
+
this.#expandCorner = placement.corner;
|
|
2458
|
+
// The corner the panel grows from, for the open/close animation's origin.
|
|
2459
|
+
this.setAttribute("data-expand-corner", `${placement.corner.y}-${placement.corner.x}`);
|
|
2460
|
+
this.#syncResizeAnchor();
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
/** Move the launcher live during a drag, without persisting. */
|
|
2464
|
+
#moveLauncher(left: number, top: number): void {
|
|
2465
|
+
this.#applyLauncherPlacement({ left, top });
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
/** Move the launcher and remember where, per tab, like the dragged size. */
|
|
2469
|
+
#commitLauncher(left: number, top: number): void {
|
|
2470
|
+
this.#moveLauncher(left, top);
|
|
2471
|
+
this.#storeLauncherPosition();
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
/** Write the current launcher position, if this element owns one. */
|
|
2475
|
+
#storeLauncherPosition(): void {
|
|
2476
|
+
const position = this.#launcherPos;
|
|
2477
|
+
if (position === null) {
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
writeStoredItem(this.#storageKey(LAUNCHER_KEY), JSON.stringify(position));
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
/**
|
|
2484
|
+
* Re-apply the dragged position against the current viewport: on connect,
|
|
2485
|
+
* whenever the window resizes, and before an expand. The viewport that
|
|
2486
|
+
* stored a position may since have shrunk, and a launcher past the edge is
|
|
2487
|
+
* unreachable -- it is the only way back to a collapsed conversation.
|
|
2488
|
+
*/
|
|
2489
|
+
#restoreLauncherPosition(): void {
|
|
2490
|
+
const stored = this.#launcherPos ?? this.#readLauncherPosition();
|
|
2491
|
+
if (stored === null) {
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
const box = this.#launcherBox();
|
|
2495
|
+
this.#applyLauncherPlacement(
|
|
2496
|
+
clampLauncher({ ...box, left: stored.left, top: stored.top }, this.#viewport()),
|
|
2497
|
+
);
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
/** The persisted launcher position for this instance, or null. */
|
|
2501
|
+
#readLauncherPosition(): { readonly left: number; readonly top: number } | null {
|
|
2502
|
+
const raw = this.#readScopedItem(LAUNCHER_KEY);
|
|
2503
|
+
if (raw === null) {
|
|
2504
|
+
return null;
|
|
2505
|
+
}
|
|
2506
|
+
try {
|
|
2507
|
+
const parsed: unknown = JSON.parse(raw);
|
|
2508
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
2509
|
+
return null;
|
|
2510
|
+
}
|
|
2511
|
+
const { left, top } = parsed as { left?: unknown; top?: unknown };
|
|
2512
|
+
return typeof left === "number" && typeof top === "number" ? { left, top } : null;
|
|
2513
|
+
} catch {
|
|
2514
|
+
// A corrupt entry is not worth failing a mount over; fall back to the
|
|
2515
|
+
// placement's own corner.
|
|
2516
|
+
return null;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
/**
|
|
2521
|
+
* Give the position back to the host when the new placement owns it, the way
|
|
2522
|
+
* #releaseOwnedAxes gives back a size. Without this a dragged inset survives
|
|
2523
|
+
* the switch inline and pins a sidebar to wherever the floating launcher was.
|
|
2524
|
+
*/
|
|
2525
|
+
#releaseLauncherPosition(): void {
|
|
2526
|
+
if (this.#launcherDraggable()) {
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
this.#launcherPos = null;
|
|
2530
|
+
this.#expandCorner = null;
|
|
2531
|
+
this.style.removeProperty("--ag-ui-inset");
|
|
2532
|
+
this.style.removeProperty("--ag-ui-launcher-inset");
|
|
2533
|
+
this.removeAttribute("data-expand-corner");
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
/**
|
|
2537
|
+
* Apply the box a grip is asking for.
|
|
2538
|
+
*
|
|
2539
|
+
* The size is the easy half. The other half is that **dragging the edge the
|
|
2540
|
+
* layout is holding still moves the panel as well as resizing it**, and the
|
|
2541
|
+
* layout cannot express that on its own: a floating panel pinned bottom-right
|
|
2542
|
+
* cannot grow rightward, because its right edge is what the placement fixed.
|
|
2543
|
+
* So a grip on a pinned edge takes the position over -- which is the same
|
|
2544
|
+
* ownership the launcher drag takes, written the same way.
|
|
2545
|
+
*
|
|
2546
|
+
* A grip on a free edge writes nothing but the size, exactly as before, so a
|
|
2547
|
+
* host positioning the panel with its own rule keeps that rule until someone
|
|
2548
|
+
* drags the edge it was holding.
|
|
2549
|
+
*/
|
|
2550
|
+
#applyResize(grip: ResizeGrip, box: PanelRect): void {
|
|
2551
|
+
this.#applySize({ width: box.right - box.left, height: box.bottom - box.top });
|
|
2552
|
+
if (grip.x !== this.#anchor.x && grip.y !== this.#anchor.y) {
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
const viewport = this.#viewport();
|
|
2556
|
+
const anchor = this.#anchor;
|
|
2557
|
+
const side = (value: number): string => `${Math.round(value)}px`;
|
|
2558
|
+
this.style.setProperty(
|
|
2559
|
+
"--ag-ui-inset",
|
|
2560
|
+
[
|
|
2561
|
+
anchor.y === "top" ? side(box.top) : "auto",
|
|
2562
|
+
anchor.x === "right" ? side(viewport.width - box.right) : "auto",
|
|
2563
|
+
anchor.y === "bottom" ? side(viewport.height - box.bottom) : "auto",
|
|
2564
|
+
anchor.x === "left" ? side(box.left) : "auto",
|
|
2565
|
+
].join(" "),
|
|
2566
|
+
);
|
|
2567
|
+
// The launcher lives at this corner of the panel, so a corner that moved
|
|
2568
|
+
// takes it along. Without this the next expand would re-derive the panel's
|
|
2569
|
+
// position from a launcher still standing where the panel used to be, and
|
|
2570
|
+
// undo the move.
|
|
2571
|
+
if (this.#launcherPos !== null) {
|
|
2572
|
+
const size = this.#launcher.offsetWidth;
|
|
2573
|
+
this.#launcherPos = {
|
|
2574
|
+
left: anchor.x === "left" ? box.left : box.right - size,
|
|
2575
|
+
top: anchor.y === "top" ? box.top : box.bottom - size,
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
/** Finish a resize: keep the box, remember it, and re-read the pinned edges. */
|
|
2581
|
+
#commitResize(grip: ResizeGrip, box: PanelRect): void {
|
|
2582
|
+
this.#applyResize(grip, box);
|
|
2583
|
+
this.#persistSize({ width: box.right - box.left, height: box.bottom - box.top });
|
|
2584
|
+
this.#storeLauncherPosition();
|
|
2585
|
+
// Re-stamp after the drag: a host whose layout changed underneath us would
|
|
2586
|
+
// otherwise keep the tab-reachable grip in the old corner, which reads as
|
|
2587
|
+
// the control being in the wrong place even though the drag was right.
|
|
2588
|
+
this.#syncResizeAnchor();
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
/**
|
|
2592
|
+
* Put the tab stop on the grip diagonally opposite the pinned corner.
|
|
2593
|
+
*
|
|
2594
|
+
* That is the corner a resize grows the panel from, so an arrow key there
|
|
2595
|
+
* changes the size and never the position -- the behaviour the single grip
|
|
2596
|
+
* this replaced had, kept for the one path that cannot simply grab a
|
|
2597
|
+
* different edge.
|
|
2598
|
+
*/
|
|
2599
|
+
#focusableGrip(): void {
|
|
2600
|
+
const free = `${this.#anchor.y === "top" ? "bottom" : "top"}-${
|
|
2601
|
+
this.#anchor.x === "left" ? "right" : "left"
|
|
2602
|
+
}`;
|
|
2603
|
+
for (const [name, handle] of this.#resizeHandles) {
|
|
2604
|
+
const reachable = name === free;
|
|
2605
|
+
handle.tabIndex = reachable ? 0 : -1;
|
|
2606
|
+
if (reachable) {
|
|
2607
|
+
handle.removeAttribute("aria-hidden");
|
|
2608
|
+
} else {
|
|
2609
|
+
handle.setAttribute("aria-hidden", "true");
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2202
2614
|
/** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
|
|
2203
2615
|
#persistSize(size: ResizeSize): void {
|
|
2204
2616
|
const stored = { ...this.#readSize(), ...size };
|
|
@@ -2473,6 +2885,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
2473
2885
|
// the correct half of that split -- a delegation that was live before this
|
|
2474
2886
|
// transcript was wiped is not live now.
|
|
2475
2887
|
this.#subagentPanels.clear();
|
|
2888
|
+
this.#subagentRunDelegations.clear();
|
|
2476
2889
|
this.#serverSettled.clear();
|
|
2477
2890
|
this.#cardElements.clear();
|
|
2478
2891
|
this.#activityBlocks.clear();
|
|
@@ -3085,23 +3498,36 @@ export class AgUiChat extends HTMLElement {
|
|
|
3085
3498
|
this.#badge,
|
|
3086
3499
|
);
|
|
3087
3500
|
this.#launcher.addEventListener("click", () => this.setCollapsed(false));
|
|
3501
|
+
// Only while collapsed: the launcher is scaled away and unclickable behind
|
|
3502
|
+
// the open panel, so a drag there would move something nobody can see.
|
|
3503
|
+
enableLauncherDrag(this.#launcher, {
|
|
3504
|
+
enabled: () => this.collapsed && this.#launcherDraggable(),
|
|
3505
|
+
rect: () => this.#launcherBox(),
|
|
3506
|
+
viewport: () => this.#viewport(),
|
|
3507
|
+
apply: (left, top) => this.#moveLauncher(left, top),
|
|
3508
|
+
commit: (left, top) => this.#commitLauncher(left, top),
|
|
3509
|
+
});
|
|
3088
3510
|
|
|
3089
|
-
|
|
3090
|
-
createResizeHandle({
|
|
3511
|
+
for (const grip of RESIZE_GRIPS) {
|
|
3512
|
+
const handle = createResizeHandle(grip, {
|
|
3091
3513
|
axis: () => this.#resizeAxis(),
|
|
3092
|
-
anchor: () => this.#measureAnchor(),
|
|
3093
3514
|
rect: () => this.getBoundingClientRect(),
|
|
3094
|
-
apply: (
|
|
3095
|
-
commit: (
|
|
3096
|
-
this.#persistSize(size);
|
|
3097
|
-
// Re-stamp after the drag: a host whose layout changed underneath us
|
|
3098
|
-
// would otherwise keep the grip in the old corner, which reads as the
|
|
3099
|
-
// control being in the wrong place even though the drag was right.
|
|
3100
|
-
this.#syncResizeAnchor();
|
|
3101
|
-
},
|
|
3515
|
+
apply: (box) => this.#applyResize(grip, box),
|
|
3516
|
+
commit: (box) => this.#commitResize(grip, box),
|
|
3102
3517
|
label: this.#strings.resizePanel,
|
|
3103
|
-
})
|
|
3104
|
-
|
|
3518
|
+
});
|
|
3519
|
+
// Only one of the eight is in the tab order. Eight separators between the
|
|
3520
|
+
// transcript and the composer is not keyboard parity, it is a keyboard
|
|
3521
|
+
// obstacle -- and one grip already reaches both axes, which is exactly
|
|
3522
|
+
// what the single grip this replaced offered. #syncResizeAnchor decides
|
|
3523
|
+
// which one, and it is the free corner, so an arrow key grows the panel
|
|
3524
|
+
// rather than moving it.
|
|
3525
|
+
handle.tabIndex = -1;
|
|
3526
|
+
handle.setAttribute("aria-hidden", "true");
|
|
3527
|
+
this.#resizeHandles.set(gripName(grip), handle);
|
|
3528
|
+
this.#chat.appendChild(handle);
|
|
3529
|
+
}
|
|
3530
|
+
this.#focusableGrip();
|
|
3105
3531
|
this.#adoptStyles();
|
|
3106
3532
|
this.#root.append(this.#announcer, this.#chat, this.#launcher);
|
|
3107
3533
|
}
|
|
@@ -3549,14 +3975,21 @@ export class AgUiChat extends HTMLElement {
|
|
|
3549
3975
|
strings: this.#strings,
|
|
3550
3976
|
// Read at click time, not captured: a bubble rendered from markdown
|
|
3551
3977
|
// holds its text in the DOM, and that is what the user sees and means
|
|
3552
|
-
// to copy.
|
|
3553
|
-
|
|
3978
|
+
// to copy. Serialised rather than read off `textContent`, which welds
|
|
3979
|
+
// a table into one run of digits and picks up the code blocks' own
|
|
3980
|
+
// copy buttons on the way past.
|
|
3981
|
+
...(copyable
|
|
3982
|
+
? {
|
|
3983
|
+
text: () => copyPayload(bubble).text,
|
|
3984
|
+
html: () => copyPayload(bubble).html,
|
|
3985
|
+
}
|
|
3986
|
+
: {}),
|
|
3554
3987
|
...(rateable
|
|
3555
3988
|
? {
|
|
3556
3989
|
onFeedback: (rating: "up" | "down") => {
|
|
3557
3990
|
this.dispatchEvent(
|
|
3558
3991
|
new CustomEvent<FeedbackDetail>(FEEDBACK_EVENT, {
|
|
3559
|
-
detail: { content: bubble.
|
|
3992
|
+
detail: { content: copyPayload(bubble).text, rating },
|
|
3560
3993
|
bubbles: true,
|
|
3561
3994
|
composed: true,
|
|
3562
3995
|
}),
|
|
@@ -3574,7 +4007,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
3574
4007
|
/** Move the Retry button onto `bar`, taking it off whoever held it. */
|
|
3575
4008
|
#moveRetryTo(bar: HTMLElement): void {
|
|
3576
4009
|
this.#retryOwner?.querySelector(".message-action--retry")?.remove();
|
|
3577
|
-
const retry = messageActionButton("retry", this.#strings.retryMessage,
|
|
4010
|
+
const retry = messageActionButton("retry", this.#strings.retryMessage, ICON_RETRY);
|
|
3578
4011
|
retry.addEventListener("click", () => {
|
|
3579
4012
|
void this.retryLastTurn();
|
|
3580
4013
|
});
|
|
@@ -3945,6 +4378,43 @@ export class AgUiChat extends HTMLElement {
|
|
|
3945
4378
|
}),
|
|
3946
4379
|
);
|
|
3947
4380
|
},
|
|
4381
|
+
// The delegation's own lifetime, on the protocol's events rather than the
|
|
4382
|
+
// CUSTOM channel its steps ride. Both end at the same panel.
|
|
4383
|
+
onSubAgentStarted: (subagentRunId, agent, parentToolCallId) => {
|
|
4384
|
+
// A delegation naming no parent call names no card, and a floating
|
|
4385
|
+
// panel is exactly what attaching to the card was chosen over.
|
|
4386
|
+
if (parentToolCallId === null) {
|
|
4387
|
+
return;
|
|
4388
|
+
}
|
|
4389
|
+
this.#subagentRunDelegations.set(subagentRunId, parentToolCallId);
|
|
4390
|
+
this.#applySubAgent({
|
|
4391
|
+
delegationId: parentToolCallId,
|
|
4392
|
+
agent: agent === "" ? null : agent,
|
|
4393
|
+
phase: SUBAGENT_PHASE.STARTED,
|
|
4394
|
+
status: this.#strings.subAgentDelegatedTo.replace("{agent}", agent),
|
|
4395
|
+
tool: null,
|
|
4396
|
+
});
|
|
4397
|
+
},
|
|
4398
|
+
onSubAgentFinished: (subagentRunId) => {
|
|
4399
|
+
this.#closeSubAgent(subagentRunId, SUBAGENT_PHASE.FINISHED, null);
|
|
4400
|
+
},
|
|
4401
|
+
onSubAgentError: (subagentRunId, message) => {
|
|
4402
|
+
// The server's own words, which the contract keeps to the sub-agent's
|
|
4403
|
+
// name. Passed through as the status line and set with textContent
|
|
4404
|
+
// downstream, never parsed as markup.
|
|
4405
|
+
//
|
|
4406
|
+
// The message is required by the protocol and can still arrive empty,
|
|
4407
|
+
// which would settle the row to a blank line -- a delegation that reads
|
|
4408
|
+
// as having said nothing rather than as having failed. The fallback was
|
|
4409
|
+
// written and documented in UiStrings and never wired up, so until now
|
|
4410
|
+
// the only reader who knew it existed was the one reading the string
|
|
4411
|
+
// table.
|
|
4412
|
+
this.#closeSubAgent(
|
|
4413
|
+
subagentRunId,
|
|
4414
|
+
SUBAGENT_PHASE.FAILED,
|
|
4415
|
+
message === "" ? this.#strings.subAgentFailed : message,
|
|
4416
|
+
);
|
|
4417
|
+
},
|
|
3948
4418
|
onMessagesSnapshot: () => {
|
|
3949
4419
|
// Honoured for persistence and announced, not re-rendered.
|
|
3950
4420
|
//
|
|
@@ -4144,6 +4614,57 @@ export class AgUiChat extends HTMLElement {
|
|
|
4144
4614
|
if (update === null) {
|
|
4145
4615
|
return;
|
|
4146
4616
|
}
|
|
4617
|
+
this.#applySubAgent(update);
|
|
4618
|
+
}
|
|
4619
|
+
|
|
4620
|
+
/**
|
|
4621
|
+
* Fold one already-narrowed update into the delegation's panel.
|
|
4622
|
+
*
|
|
4623
|
+
* The join point of the two carriers, and the reason it is separate from
|
|
4624
|
+
* {@link #reportSubAgent}: a `CUSTOM` step arrives as `unknown` and has to be
|
|
4625
|
+
* vouched for, while a lifecycle event arrives typed off the protocol and has
|
|
4626
|
+
* nothing left to check. Both end up here, so the panel has one way in and
|
|
4627
|
+
* the phases stay a single state machine regardless of which wire they came
|
|
4628
|
+
* from.
|
|
4629
|
+
*/
|
|
4630
|
+
/**
|
|
4631
|
+
* Settle the delegation a closing lifecycle event names.
|
|
4632
|
+
*
|
|
4633
|
+
* `status` is the server's text on a failure and `null` on a success, where
|
|
4634
|
+
* the wording is this element's own -- the protocol's finish event carries no
|
|
4635
|
+
* message, which is the better shape for a localised UI and the reason
|
|
4636
|
+
* {@link UiStrings.subAgentFinished} exists.
|
|
4637
|
+
*
|
|
4638
|
+
* The pairing is deliberately not deleted on close. A panel outlives the
|
|
4639
|
+
* delegation it drew, the map is cleared with the transcript alongside the
|
|
4640
|
+
* panels, and forgetting the id here would only make a duplicate close draw
|
|
4641
|
+
* nothing instead of drawing the same settled row again.
|
|
4642
|
+
*/
|
|
4643
|
+
#closeSubAgent(subagentRunId: string, phase: SubAgentPhase, status: string | null): void {
|
|
4644
|
+
const delegationId = this.#subagentRunDelegations.get(subagentRunId);
|
|
4645
|
+
if (delegationId === undefined) {
|
|
4646
|
+
// A close naming a delegation this never saw open -- the same refusal a
|
|
4647
|
+
// step for an undrawn card gets, and the same reason.
|
|
4648
|
+
return;
|
|
4649
|
+
}
|
|
4650
|
+
const agent = this.#subagentPanels.get(delegationId)?.agent ?? null;
|
|
4651
|
+
this.#applySubAgent({
|
|
4652
|
+
delegationId,
|
|
4653
|
+
agent,
|
|
4654
|
+
phase,
|
|
4655
|
+
status: status === null ? this.#finishedLine(agent) : status,
|
|
4656
|
+
tool: null,
|
|
4657
|
+
});
|
|
4658
|
+
}
|
|
4659
|
+
|
|
4660
|
+
/** The row's line for a delegation that completed, named if its name is known. */
|
|
4661
|
+
#finishedLine(agent: string | null): string {
|
|
4662
|
+
return agent === null
|
|
4663
|
+
? this.#strings.subAgentWorking
|
|
4664
|
+
: this.#strings.subAgentFinished.replace("{agent}", agent);
|
|
4665
|
+
}
|
|
4666
|
+
|
|
4667
|
+
#applySubAgent(update: SubAgentUpdate): void {
|
|
4147
4668
|
const card = this.#toolCards.get(update.delegationId);
|
|
4148
4669
|
if (card === undefined) {
|
|
4149
4670
|
return;
|
|
@@ -4633,6 +5154,31 @@ function restoredToolCalls(value: unknown): readonly RestoredToolCall[] {
|
|
|
4633
5154
|
return Array.isArray(value) ? value.filter(isRestoredToolCall) : [];
|
|
4634
5155
|
}
|
|
4635
5156
|
|
|
5157
|
+
/**
|
|
5158
|
+
* A pasted file, guaranteed to have a name.
|
|
5159
|
+
*
|
|
5160
|
+
* A file dropped or picked always carries one; a pasted one need not. Some
|
|
5161
|
+
* engines hand over a blob with an empty name, which travels all the way to
|
|
5162
|
+
* the upload as an empty `filename` and lands on the server as a file nobody
|
|
5163
|
+
* can identify -- while the chip in the tray shows an empty label. A file that
|
|
5164
|
+
* already has a name keeps it, including the generic one Chrome gives a pasted
|
|
5165
|
+
* screenshot: it is at least what the file is, and the chip shows the size
|
|
5166
|
+
* beside it.
|
|
5167
|
+
*/
|
|
5168
|
+
function named(file: File): File {
|
|
5169
|
+
if (file.name !== "") {
|
|
5170
|
+
return file;
|
|
5171
|
+
}
|
|
5172
|
+
// The subtype is the extension for every clipboard image type worth naming.
|
|
5173
|
+
// A type with no slash in it falls back to the whole string, and an absent
|
|
5174
|
+
// one leaves a bare stamp rather than a name ending in a dot.
|
|
5175
|
+
const subtype = file.type.split("/")[1] ?? file.type;
|
|
5176
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
5177
|
+
return new File([file], subtype === "" ? `pasted-${stamp}` : `pasted-${stamp}.${subtype}`, {
|
|
5178
|
+
type: file.type,
|
|
5179
|
+
});
|
|
5180
|
+
}
|
|
5181
|
+
|
|
4636
5182
|
/** Whether an unknown history entry has enough shape to render a tool card. */
|
|
4637
5183
|
function isRestoredToolCall(value: unknown): value is RestoredToolCall {
|
|
4638
5184
|
if (typeof value !== "object" || value === null) {
|