@artooi/ag-ui-web-component 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +615 -1
  2. package/README.md +564 -35
  3. package/dist/ag-ui-web-component.bundle.js +491 -50
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +129 -1
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +232 -1
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +56 -1
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/index.d.ts +8 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2081 -98
  14. package/dist/index.js.map +4 -4
  15. package/dist/ui/approval_card.d.ts +18 -0
  16. package/dist/ui/approval_card.d.ts.map +1 -1
  17. package/dist/ui/checkpoint_menu.d.ts +10 -0
  18. package/dist/ui/checkpoint_menu.d.ts.map +1 -1
  19. package/dist/ui/confirmation_card.d.ts +16 -0
  20. package/dist/ui/confirmation_card.d.ts.map +1 -1
  21. package/dist/ui/message_actions.d.ts +56 -0
  22. package/dist/ui/message_actions.d.ts.map +1 -0
  23. package/dist/ui/page_quote_offer.d.ts +33 -0
  24. package/dist/ui/page_quote_offer.d.ts.map +1 -0
  25. package/dist/ui/quote_selection.d.ts +66 -0
  26. package/dist/ui/quote_selection.d.ts.map +1 -0
  27. package/dist/ui/relative_time.d.ts +10 -0
  28. package/dist/ui/relative_time.d.ts.map +1 -1
  29. package/dist/ui/stick_to_bottom.d.ts +55 -0
  30. package/dist/ui/stick_to_bottom.d.ts.map +1 -0
  31. package/dist/ui/styles.d.ts +1 -1
  32. package/dist/ui/styles.d.ts.map +1 -1
  33. package/dist/ui/subagent_panel.d.ts +92 -0
  34. package/dist/ui/subagent_panel.d.ts.map +1 -0
  35. package/dist/ui/subagent_update.d.ts +19 -0
  36. package/dist/ui/subagent_update.d.ts.map +1 -0
  37. package/dist/ui/suggestion_chips.d.ts +29 -0
  38. package/dist/ui/suggestion_chips.d.ts.map +1 -0
  39. package/dist/ui/thread_drawer.d.ts +10 -0
  40. package/dist/ui/thread_drawer.d.ts.map +1 -1
  41. package/dist/ui/tool_call_card.d.ts +81 -1
  42. package/dist/ui/tool_call_card.d.ts.map +1 -1
  43. package/dist/ui/ui_strings.d.ts +50 -0
  44. package/dist/ui/ui_strings.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/constants.ts +138 -1
  47. package/src/core/ag_ui_chat.ts +1081 -73
  48. package/src/core/agui_client.ts +89 -2
  49. package/src/index.ts +43 -0
  50. package/src/ui/approval_card.ts +90 -2
  51. package/src/ui/checkpoint_menu.ts +22 -5
  52. package/src/ui/confirmation_card.ts +29 -1
  53. package/src/ui/message_actions.ts +170 -0
  54. package/src/ui/page_quote_offer.ts +215 -0
  55. package/src/ui/quote_selection.ts +345 -0
  56. package/src/ui/relative_time.ts +11 -0
  57. package/src/ui/stick_to_bottom.ts +126 -0
  58. package/src/ui/styles.ts +410 -0
  59. package/src/ui/subagent_panel.ts +213 -0
  60. package/src/ui/subagent_update.ts +80 -0
  61. package/src/ui/suggestion_chips.ts +73 -0
  62. package/src/ui/thread_drawer.ts +22 -2
  63. package/src/ui/tool_call_card.ts +138 -3
  64. package/src/ui/ui_strings.ts +75 -0
  65. package/src/version.ts +1 -1
package/src/ui/styles.ts CHANGED
@@ -571,6 +571,7 @@ export const STYLES = `
571
571
  opacity: 0;
572
572
  }
573
573
 
574
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .messages-wrap,
574
575
  :host([collapsed]:is([placement="embedded"], [placement="page"])) .messages,
575
576
  :host([collapsed]:is([placement="embedded"], [placement="page"])) .input-row,
576
577
  :host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-chips,
@@ -579,9 +580,113 @@ export const STYLES = `
579
580
  display: none;
580
581
  }
581
582
 
583
+ /* Jump-to-latest: shown only once the reader has scrolled away *and* missed
584
+ something. Anchored to the panel rather than the list so it does not scroll
585
+ with the content it is offering to scroll to. */
586
+ /* The transcript's own box, and the only one whose foot is the transcript's
587
+ foot. The panel's foot is below the composer, the chips and the footer. */
588
+ .messages-wrap {
589
+ position: relative;
590
+ flex: 1;
591
+ min-height: 0;
592
+ display: flex;
593
+ flex-direction: column;
594
+ }
595
+
596
+ .jump-latest {
597
+ position: absolute;
598
+ left: 50%;
599
+ transform: translateX(-50%);
600
+ bottom: var(--_pad);
601
+ z-index: 2;
602
+ display: none;
603
+ align-items: center;
604
+ gap: 0.35em;
605
+ padding: 0.4em 0.9em;
606
+ border: 1px solid var(--_border);
607
+ border-radius: 999px;
608
+ /* A raised surface, not the panel's own background. Reusing --_bg made the
609
+ pill the same colour as everything behind it, leaving a 1px border and a
610
+ shadow to carry the whole affordance -- and a dark-on-dark shadow carries
611
+ nothing. --_hover is the token that already means "lifted off the panel",
612
+ and it separates in both themes without competing with the accent the send
613
+ button owns. */
614
+ background: var(--_hover);
615
+ color: var(--_text);
616
+ font: inherit;
617
+ font-size: 0.85em;
618
+ cursor: pointer;
619
+ box-shadow: 0 2px 10px rgb(0 0 0 / 0.18);
620
+ }
621
+
622
+ .jump-latest[data-missed="true"] {
623
+ display: flex;
624
+ }
625
+
626
+ .jump-latest:hover {
627
+ border-color: var(--_accent);
628
+ }
629
+
630
+ /* The offer to quote a selection. Positioned in script against the transcript
631
+ box, which is the only ancestor whose top and foot are the transcript's --
632
+ the same reason .jump-latest lives here. The translate is the half the
633
+ script does not do: script sets the point the offer hangs from, CSS decides
634
+ which corner of the offer that point is. */
635
+ .quote-selection {
636
+ position: absolute;
637
+ z-index: 2;
638
+ transform: translate(-50%, -100%);
639
+ padding: 0.25em 0.7em;
640
+ border: 1px solid var(--_border);
641
+ border-radius: 999px;
642
+ background: var(--_hover);
643
+ color: var(--_text);
644
+ font: inherit;
645
+ font-size: 0.8em;
646
+ line-height: 1.6;
647
+ white-space: nowrap;
648
+ cursor: pointer;
649
+ box-shadow: 0 2px 10px rgb(0 0 0 / 0.18);
650
+ }
651
+
652
+ /* Flipped under the selection when there was no room above it. Only the
653
+ vertical half of the translate changes: it still hangs from its own centre
654
+ horizontally. */
655
+ .quote-selection[data-below="true"] {
656
+ transform: translate(-50%, 0);
657
+ }
658
+
659
+ .quote-selection:hover {
660
+ border-color: var(--_accent);
661
+ }
662
+
663
+ /* Screen-reader-only status region. Off-screen rather than display:none or
664
+ visibility:hidden, both of which take the element out of the accessibility
665
+ tree entirely -- a hidden live region announces nothing at all, which is the
666
+ classic way this pattern is written wrong.
667
+
668
+ The 1px box with clip-path, rather than width/height 0, is the shape that
669
+ survives: a zero-sized element is dropped from the tree by some engines. */
670
+ .sr-only {
671
+ position: absolute;
672
+ width: 1px;
673
+ height: 1px;
674
+ margin: -1px;
675
+ padding: 0;
676
+ border: 0;
677
+ overflow: hidden;
678
+ white-space: nowrap;
679
+ clip-path: inset(50%);
680
+ }
681
+
582
682
  .messages {
583
683
  flex: 1;
584
684
  overflow-y: auto;
685
+ /* The browser's own scroll anchoring competes with the scroller for the same
686
+ job and wins unpredictably -- it can hold the view still exactly when we
687
+ want to follow. Turned off so following is decided in one place. Safari
688
+ does not implement it, which is itself a reason not to depend on it. */
689
+ overflow-anchor: none;
585
690
  padding: var(--_pad);
586
691
  display: flex;
587
692
  flex-direction: column;
@@ -1090,6 +1195,21 @@ export const STYLES = `
1090
1195
  color: var(--_fg);
1091
1196
  }
1092
1197
 
1198
+ /* A region a host formatter took over, marked by the card. Preformatted
1199
+ whitespace is what makes the built-in block read as written, and it is the one
1200
+ thing a host cannot want: a table inherits it as mangled cell spacing, and a
1201
+ sentence as line breaks nobody typed.
1202
+
1203
+ Whitespace only. The card's own face, frame, padding and scroll cap stay,
1204
+ because the card is one visual object -- the head row and the status pill are
1205
+ monospaced too -- and a region that dropped the family would be the only part
1206
+ of it wearing a different one. A host that wants that restyles the
1207
+ tool-card-result part, which does not need the formatter at all. */
1208
+ .tool-call-args[data-formatted],
1209
+ .tool-call-result[data-formatted] {
1210
+ white-space: normal;
1211
+ }
1212
+
1093
1213
  /* Display modes are pure visibility over one DOM shape, selected from the host
1094
1214
  attribute rather than a value stamped on the card at build time, so flipping
1095
1215
  data-tool-display re-styles cards already on screen. See ToolCallCard.
@@ -1154,6 +1274,174 @@ export const STYLES = `
1154
1274
  display: flex;
1155
1275
  }
1156
1276
 
1277
+ /* A delegated sub-agent's progress, inside the card that delegated. Empty on
1278
+ every card that delegated nothing, so it collapses rather than adding a gap
1279
+ to each one -- the same shape the approval slot uses. */
1280
+ .tool-call-subagent:empty {
1281
+ display: none;
1282
+ }
1283
+
1284
+ .tool-call-subagent {
1285
+ display: flex;
1286
+ flex-direction: column;
1287
+ gap: 4px;
1288
+ }
1289
+
1290
+ .subagent {
1291
+ display: flex;
1292
+ flex-direction: column;
1293
+ gap: 4px;
1294
+ min-width: 0;
1295
+ }
1296
+
1297
+ /* The collapsed row is the status and the expander at once, which is what keeps
1298
+ a ten-step child one row until somebody opens it. Full width and left-aligned,
1299
+ because it is a line of the card rather than a button on it. */
1300
+ .subagent-row {
1301
+ display: flex;
1302
+ align-items: center;
1303
+ gap: 6px;
1304
+ width: 100%;
1305
+ box-sizing: border-box;
1306
+ padding: 2px 0;
1307
+ border: none;
1308
+ background: none;
1309
+ font: inherit;
1310
+ text-align: left;
1311
+ color: var(--_muted);
1312
+ cursor: pointer;
1313
+ }
1314
+
1315
+ /* Nothing behind the row yet -- a delegation that failed before calling
1316
+ anything. Drop the affordances rather than offer a control that expands onto
1317
+ an empty region, which is the refusal the card's own toggle already makes. */
1318
+ .subagent-row:disabled {
1319
+ cursor: default;
1320
+ }
1321
+
1322
+ .subagent-row::after {
1323
+ content: "▸";
1324
+ flex: none;
1325
+ margin-left: auto;
1326
+ color: var(--_accent);
1327
+ }
1328
+
1329
+ .subagent-row[aria-expanded="true"]::after {
1330
+ content: "▾";
1331
+ }
1332
+
1333
+ .subagent-row:disabled::after {
1334
+ display: none;
1335
+ }
1336
+
1337
+ /* Empty in the DOM; the glyph is drawn here from the panel's data-phase, so a
1338
+ host re-themes it through the same tool-icon custom properties the card uses. */
1339
+ .subagent-icon {
1340
+ flex: none;
1341
+ box-sizing: border-box;
1342
+ display: inline-flex;
1343
+ align-items: center;
1344
+ justify-content: center;
1345
+ width: 10px;
1346
+ height: 10px;
1347
+ font-size: 10px;
1348
+ line-height: 1;
1349
+ }
1350
+
1351
+ /* Anything that is not a terminal phase is the child still working. Selected by
1352
+ what it is not, so a phase this client has not heard of still spins rather
1353
+ than rendering as a blank. */
1354
+ .subagent[data-phase]:not([data-phase="finished"]):not([data-phase="failed"]) .subagent-icon {
1355
+ border: 2px solid var(--_muted);
1356
+ border-top-color: transparent;
1357
+ border-radius: 50%;
1358
+ animation: ag-ui-tool-spin var(--_tool-spin-duration) linear infinite;
1359
+ }
1360
+
1361
+ .subagent[data-phase="finished"] .subagent-icon::before {
1362
+ content: var(--_tool-icon-done);
1363
+ color: var(--_success);
1364
+ }
1365
+
1366
+ .subagent[data-phase="failed"] .subagent-icon::before {
1367
+ content: var(--_tool-icon-error);
1368
+ color: var(--_danger);
1369
+ }
1370
+
1371
+ @media (prefers-reduced-motion: reduce) {
1372
+ .subagent .subagent-icon {
1373
+ animation: none;
1374
+ }
1375
+ }
1376
+
1377
+ /* The server's own pre-rendered line. Shrinks and wraps rather than pushing the
1378
+ chevron out of the card, which is what a fixed-width sibling in a flex row
1379
+ does to a panel at sidebar width. */
1380
+ .subagent-status {
1381
+ flex: 1 1 auto;
1382
+ min-width: 0;
1383
+ overflow-wrap: anywhere;
1384
+ }
1385
+
1386
+ /* The child's own calls. Indented and ruled, so the nesting is visible without
1387
+ a second card frame around it. */
1388
+ .subagent-steps {
1389
+ display: flex;
1390
+ flex-direction: column;
1391
+ gap: 2px;
1392
+ margin-left: 4px;
1393
+ padding-left: 10px;
1394
+ border-left: 1px solid var(--_border);
1395
+ }
1396
+
1397
+ .subagent-steps[hidden] {
1398
+ display: none;
1399
+ }
1400
+
1401
+ .subagent-step {
1402
+ display: flex;
1403
+ align-items: center;
1404
+ gap: 6px;
1405
+ min-width: 0;
1406
+ color: var(--_muted);
1407
+ }
1408
+
1409
+ .subagent-step-icon {
1410
+ flex: none;
1411
+ box-sizing: border-box;
1412
+ display: inline-flex;
1413
+ align-items: center;
1414
+ justify-content: center;
1415
+ width: 8px;
1416
+ height: 8px;
1417
+ font-size: 9px;
1418
+ line-height: 1;
1419
+ }
1420
+
1421
+ /* No outcome yet: the wire says null while the call is in flight, and the
1422
+ absence of the attribute is how that arrives here. A hollow ring, not a
1423
+ spinner -- several can be on screen at once and the row above already spins. */
1424
+ .subagent-step:not([data-ok]) .subagent-step-icon {
1425
+ border: 1px solid var(--_muted);
1426
+ border-radius: 50%;
1427
+ }
1428
+
1429
+ .subagent-step[data-ok="true"] .subagent-step-icon::before {
1430
+ content: var(--_tool-icon-done);
1431
+ color: var(--_success);
1432
+ }
1433
+
1434
+ .subagent-step[data-ok="false"] .subagent-step-icon::before {
1435
+ content: var(--_tool-icon-error);
1436
+ color: var(--_danger);
1437
+ }
1438
+
1439
+ .subagent-step-name {
1440
+ flex: 1 1 auto;
1441
+ min-width: 0;
1442
+ overflow-wrap: anywhere;
1443
+ }
1444
+
1157
1445
  .tool-call-toggle {
1158
1446
  align-self: flex-start;
1159
1447
  border: none;
@@ -1747,6 +2035,7 @@ export const STYLES = `
1747
2035
 
1748
2036
  .confirm-actions {
1749
2037
  display: flex;
2038
+ flex-wrap: wrap;
1750
2039
  gap: 8px;
1751
2040
  justify-content: flex-end;
1752
2041
  }
@@ -1773,6 +2062,99 @@ export const STYLES = `
1773
2062
  color: #ffffff;
1774
2063
  }
1775
2064
 
2065
+ /* The session waiver. Deliberately the quietest of the three: it is the widest
2066
+ decision on the card, so it should be reachable without being the one the eye
2067
+ lands on when the user means to say yes once. */
2068
+ .confirm-btn--always {
2069
+ font-weight: 500;
2070
+ opacity: 0.85;
2071
+ }
2072
+
2073
+ .confirm-btn--always:hover,
2074
+ .confirm-btn--always:focus-visible {
2075
+ opacity: 1;
2076
+ }
2077
+
2078
+ /* Editable arguments on an approval card. A plain field rather than a code
2079
+ editor: it holds the JSON a card already displays, and the only interaction
2080
+ is correcting a value before letting the call run. */
2081
+ .approval-edit {
2082
+ display: flex;
2083
+ flex-direction: column;
2084
+ gap: 6px;
2085
+ }
2086
+
2087
+ .approval-args {
2088
+ box-sizing: border-box;
2089
+ width: 100%;
2090
+ resize: vertical;
2091
+ border: 1px solid var(--_border);
2092
+ border-radius: 8px;
2093
+ padding: 8px;
2094
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2095
+ font-size: 0.85em;
2096
+ background: var(--_bg);
2097
+ color: var(--_fg);
2098
+ }
2099
+
2100
+ .approval-args:focus-visible {
2101
+ border-color: var(--_accent);
2102
+ outline: none;
2103
+ }
2104
+
2105
+ .approval-error {
2106
+ font-size: 0.85em;
2107
+ color: var(--_danger);
2108
+ }
2109
+
2110
+ /* Message action row. Sits under a finished assistant bubble.
2111
+
2112
+ The wrap is insurance rather than a fix: these buttons are glyph-only, so at
2113
+ every width tested they fit on one line and removing the wrap changes
2114
+ nothing. It is here because the confirmation row one release earlier did
2115
+ overflow when it gained a third button, off the left edge and outside its own
2116
+ card, and the difference between the two rows is only that this one's labels
2117
+ are icons today. */
2118
+ .message-actions {
2119
+ display: flex;
2120
+ flex-wrap: wrap;
2121
+ gap: 4px;
2122
+ /* Negative, and that is the point. The answer group is a flex column with its
2123
+ own gap, so a positive margin here pushes the row further from the message
2124
+ it acts on than the next card is below it -- the buttons then read as
2125
+ belonging to whatever follows. Pulling back inside the gap is what makes
2126
+ them the message's own. */
2127
+ margin-top: -6px;
2128
+ }
2129
+
2130
+ .message-action {
2131
+ border: none;
2132
+ border-radius: 6px;
2133
+ padding: 2px 6px;
2134
+ font: inherit;
2135
+ line-height: 1.2;
2136
+ cursor: pointer;
2137
+ background: transparent;
2138
+ color: var(--_muted);
2139
+ opacity: 0.75;
2140
+ }
2141
+
2142
+ .message-action:hover,
2143
+ .message-action:focus-visible {
2144
+ opacity: 1;
2145
+ background: var(--_border);
2146
+ }
2147
+
2148
+ .message-action[aria-pressed="true"] {
2149
+ opacity: 1;
2150
+ color: var(--_accent);
2151
+ }
2152
+
2153
+ .message-action--confirmed {
2154
+ opacity: 1;
2155
+ color: var(--_accent);
2156
+ }
2157
+
1776
2158
  /* Approval card — the server-side-tool gate (approve/deny an interrupt). */
1777
2159
  .approval {
1778
2160
  align-self: stretch;
@@ -1917,6 +2299,34 @@ export const STYLES = `
1917
2299
  border-color: var(--_accent);
1918
2300
  }
1919
2301
 
2302
+ /* Follow-up suggestion chips. Deliberately the skill chips' shape rather than a
2303
+ second chip vocabulary -- both are "a question you could ask", and the only
2304
+ difference is who chose it. Inside the transcript, so they scroll with the
2305
+ answer they follow instead of hovering above the composer. */
2306
+ .suggestions {
2307
+ display: flex;
2308
+ flex-wrap: wrap;
2309
+ gap: 6px;
2310
+ align-self: stretch;
2311
+ }
2312
+
2313
+ .suggestion-chip {
2314
+ border: 1px solid var(--_border);
2315
+ border-radius: 999px;
2316
+ padding: 4px 12px;
2317
+ font: inherit;
2318
+ font-size: 0.9em;
2319
+ text-align: left;
2320
+ cursor: pointer;
2321
+ background: var(--_assistant-bg);
2322
+ color: var(--_fg);
2323
+ }
2324
+
2325
+ .suggestion-chip:hover,
2326
+ .suggestion-chip:focus-visible {
2327
+ border-color: var(--_accent);
2328
+ }
2329
+
1920
2330
  .skill-palette {
1921
2331
  margin: 8px 12px 0;
1922
2332
  display: flex;
@@ -0,0 +1,213 @@
1
+ import type { SUBAGENT_PHASE } from "../constants.js";
2
+ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
3
+
4
+ /** One phase of a delegation's life, as the server spells it. */
5
+ export type SubAgentPhase = (typeof SUBAGENT_PHASE)[keyof typeof SUBAGENT_PHASE];
6
+
7
+ /**
8
+ * One tool call the child made, as the two tool phases report it.
9
+ *
10
+ * `ok` is a tri-state and stays one here rather than collapsing to a boolean:
11
+ * `null` is the call in flight, `true` a result the child accepted, `false` a
12
+ * result that came back to it. Flattening `null` into `false` would draw a
13
+ * running call as a failed one for as long as it runs.
14
+ */
15
+ export interface SubAgentTool {
16
+ readonly toolCallId: string;
17
+ readonly name: string;
18
+ readonly ok: boolean | null;
19
+ }
20
+
21
+ /**
22
+ * One narrowed progress announcement about a delegation.
23
+ *
24
+ * Every field but `phase` and `delegationId` is nullable because the wire's
25
+ * `value` is `unknown` and a malformed announcement must not take the run down
26
+ * with it. `null` means "said nothing about this", never "said nothing was
27
+ * there" — the panel leaves what it already shows alone.
28
+ */
29
+ export interface SubAgentUpdate {
30
+ /** The **parent's** `delegate_task` tool-call id, not the child's run id. */
31
+ readonly delegationId: string;
32
+ /** The child agent's name, for a host that wants to style or select by it. */
33
+ readonly agent: string | null;
34
+ readonly phase: SubAgentPhase;
35
+ /** The server's pre-rendered line. The collapsed row needs nothing else. */
36
+ readonly status: string | null;
37
+ /** Present on the two tool phases only. */
38
+ readonly tool: SubAgentTool | null;
39
+ }
40
+
41
+ /**
42
+ * The nested surface for one delegation: a collapsed status row that expands
43
+ * onto the child agent's own tool calls.
44
+ *
45
+ * ## Where it goes and why
46
+ *
47
+ * Into {@link ToolCallCard.subagentSlot} — the card the parent's own
48
+ * `delegate_task` call already drew. The wire keys progress on the *parent's*
49
+ * tool-call id, so the thing being narrated is already on screen; a floating
50
+ * element would have duplicated its identity and then had to explain the
51
+ * relationship. Attaching instead means the delegation reuses how tool cards
52
+ * already behave, and there is no second visual language to learn.
53
+ *
54
+ * ## The shape
55
+ *
56
+ * One row per delegation, live, carrying nothing but the server's own `status`
57
+ * line — which is what makes a ten-step child cost one row until somebody opens
58
+ * it. Two alternatives were rejected on the way here and both are worth naming:
59
+ * a bare status line is cheaper and gives up the detail entirely, and inline
60
+ * child cards in the transcript interleave parent and child with nothing marking
61
+ * whose is whose, in an order the persisted transcript will not reproduce.
62
+ *
63
+ * A child's steps are keyed by the child's own `toolCallId`, so the `tool_call`
64
+ * that opens one and the `tool_result` that settles it are the same row updated
65
+ * in place rather than two rows stacked.
66
+ *
67
+ * ## What it never does
68
+ *
69
+ * It never renders failure text. A `failed` phase carries none, on purpose; the
70
+ * detail arrives on the ordinary `TOOL_CALL_RESULT` and lands in the same card's
71
+ * result region, a few pixels below. Anything invented here would be this
72
+ * component guessing at words the server declined to send.
73
+ *
74
+ * Nothing here is persisted: the events ride the imperative carrier, so a thread
75
+ * restore rebuilds the tool card and not the delegation under it.
76
+ *
77
+ * Pure DOM, like the other widgets: the host appends {@link element}, and all
78
+ * chrome text comes from {@link UiStrings}. The status line is server text and
79
+ * is set with `textContent`, never parsed as markup.
80
+ */
81
+ export class SubAgentPanel {
82
+ /** The panel's root; append this into the delegating card's slot. */
83
+ readonly element: HTMLDivElement;
84
+
85
+ /**
86
+ * The collapsed row, which is the expander as well as the status.
87
+ *
88
+ * Disabled while the child has called nothing, so a delegation that failed
89
+ * before it started offers no control that expands onto an empty region —
90
+ * the same refusal the card's own Details toggle already makes.
91
+ */
92
+ readonly #row: HTMLButtonElement;
93
+ readonly #status: HTMLSpanElement;
94
+ readonly #steps: HTMLDivElement;
95
+ /** The child's tool calls, keyed by the child's own call id. */
96
+ readonly #stepRows = new Map<string, HTMLDivElement>();
97
+
98
+ constructor(strings: UiStrings = DEFAULT_UI_STRINGS) {
99
+ this.element = document.createElement("div");
100
+ this.element.className = "subagent";
101
+ this.element.setAttribute("part", "subagent");
102
+
103
+ this.#row = document.createElement("button");
104
+ this.#row.type = "button";
105
+ this.#row.className = "subagent-row";
106
+ this.#row.setAttribute("part", "subagent-row");
107
+ this.#row.setAttribute("aria-expanded", "false");
108
+ this.#row.disabled = true;
109
+
110
+ // Left empty in the DOM, like the tool card's: the shadow CSS draws a
111
+ // spinner or a settled mark from the panel's data-phase, so a host themes
112
+ // the glyph without either side reaching into the other's stylesheet.
113
+ const icon = document.createElement("span");
114
+ icon.className = "subagent-icon";
115
+ icon.setAttribute("part", "subagent-icon");
116
+ icon.setAttribute("aria-hidden", "true");
117
+
118
+ this.#status = document.createElement("span");
119
+ this.#status.className = "subagent-status";
120
+ this.#status.setAttribute("part", "subagent-status");
121
+ // Seeded rather than left blank: an announcement whose status field is
122
+ // unusable must still leave a readable row, since the row is the control.
123
+ this.#status.textContent = strings.subAgentWorking;
124
+
125
+ this.#row.append(icon, this.#status);
126
+
127
+ this.#steps = document.createElement("div");
128
+ this.#steps.className = "subagent-steps";
129
+ this.#steps.setAttribute("part", "subagent-steps");
130
+ this.#steps.setAttribute("role", "list");
131
+ this.#steps.setAttribute("aria-label", strings.subAgentSteps);
132
+ this.#steps.hidden = true;
133
+
134
+ // The attribute is the state, as it is on the tool card: one place holds
135
+ // whether the region is open, and it is the one a screen reader reads.
136
+ this.#row.addEventListener("click", () => {
137
+ this.#setExpanded(this.#row.getAttribute("aria-expanded") !== "true");
138
+ });
139
+
140
+ this.element.append(this.#row, this.#steps);
141
+ }
142
+
143
+ /**
144
+ * Fold one announcement in.
145
+ *
146
+ * Every field is applied only when the update actually carried it, so a phase
147
+ * that says nothing about the agent or the status leaves both as they stand.
148
+ * That is what lets `finished` be two keys wide on the wire without blanking
149
+ * the row it closes.
150
+ */
151
+ report(update: SubAgentUpdate): void {
152
+ this.element.setAttribute("data-phase", update.phase);
153
+ if (update.agent !== null) {
154
+ this.element.setAttribute("data-agent", update.agent);
155
+ }
156
+ if (update.status !== null) {
157
+ this.#status.textContent = update.status;
158
+ }
159
+ if (update.tool !== null) {
160
+ this.#recordStep(update.tool);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Open or settle one of the child's calls, keyed by its own id.
166
+ *
167
+ * The absence of `data-ok` is what "still running" looks like, mirroring the
168
+ * wire's `null` rather than inventing a third value for it — so the attribute
169
+ * is removed on the way in and written on the way out.
170
+ */
171
+ #recordStep(tool: SubAgentTool): void {
172
+ const row = this.#stepRows.get(tool.toolCallId) ?? this.#createStep(tool);
173
+ if (tool.ok === null) {
174
+ row.removeAttribute("data-ok");
175
+ return;
176
+ }
177
+ row.setAttribute("data-ok", String(tool.ok));
178
+ }
179
+
180
+ #createStep(tool: SubAgentTool): HTMLDivElement {
181
+ const row = document.createElement("div");
182
+ row.className = "subagent-step";
183
+ row.setAttribute("part", "subagent-step");
184
+ row.setAttribute("role", "listitem");
185
+ row.setAttribute("data-tool-call-id", tool.toolCallId);
186
+
187
+ const icon = document.createElement("span");
188
+ icon.className = "subagent-step-icon";
189
+ icon.setAttribute("part", "subagent-step-icon");
190
+ icon.setAttribute("aria-hidden", "true");
191
+
192
+ const name = document.createElement("span");
193
+ name.className = "subagent-step-name";
194
+ name.setAttribute("part", "subagent-step-name");
195
+ // The child's raw tool name. Not prettified and not looked up in the tool
196
+ // catalog: a sub-agent's tools are its own and never reached the browser's
197
+ // schema, and the status line above quotes the same raw name, so relabelling
198
+ // here would make the two lines disagree about one call.
199
+ name.textContent = tool.name;
200
+
201
+ row.append(icon, name);
202
+ this.#steps.appendChild(row);
203
+ this.#stepRows.set(tool.toolCallId, row);
204
+ // There is something behind the row now, so it becomes a control.
205
+ this.#row.disabled = false;
206
+ return row;
207
+ }
208
+
209
+ #setExpanded(expanded: boolean): void {
210
+ this.#steps.hidden = !expanded;
211
+ this.#row.setAttribute("aria-expanded", String(expanded));
212
+ }
213
+ }